Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

359
pkg/renderkit/highlight.go Normal file
View File

@@ -0,0 +1,359 @@
package renderkit
import (
stdhtml "html"
"strings"
"unicode/utf8"
)
// A small, dependency-free syntax highlighter for fenced code blocks.
//
// It is a single-pass lexer, not a parser: it recognises comments, strings,
// numbers, keywords, built-in types, constants, function-call names and (for
// shell) $variables, and wraps each in <span class="tok-...">. That is
// enough to make code readable without pulling in a highlighting library or
// a CDN (this stack must work fully offline).
//
// Safety: input is gomarkdown's already-escaped block body. It is unescaped,
// tokenised, and every emitted piece is re-escaped, so no model-controlled
// text can ever become markup. The visible text is unchanged (the copy
// button still copies the original code).
// maxHighlightBytes skips highlighting for huge blocks; they are rare and
// the lexer is per-byte.
const maxHighlightBytes = 128 * 1024
type langSpec struct {
keywords, types, consts map[string]bool
lineComments []string
blockStart, blockEnd string
quotes string
tripleQuotes bool
foldCase bool // keywords are case-insensitive (SQL)
shellVars bool
jsonKeys bool
}
func wordSet(words string) map[string]bool {
m := make(map[string]bool)
for _, w := range strings.Fields(words) {
m[w] = true
}
return m
}
var (
goSpec = &langSpec{
keywords: wordSet(`break case chan const continue default defer else fallthrough for func go goto if
import interface map package range return select struct switch type var`),
types: wordSet(`string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte rune
float32 float64 complex64 complex128 bool error any`),
consts: wordSet(`true false nil iota`),
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'`",
}
jsSpec = &langSpec{
keywords: wordSet(`async await break case catch class const continue debugger default delete do else
export extends finally for from function if import in instanceof let new of return static super
switch this throw try typeof var void while with yield interface type enum implements namespace
declare readonly public private protected abstract as`),
types: wordSet(`string number boolean any unknown never object Promise Array Map Set`),
consts: wordSet(`true false null undefined NaN Infinity`),
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'`",
}
pySpec = &langSpec{
keywords: wordSet(`and as assert async await break class continue def del elif else except finally for
from global if import in is lambda nonlocal not or pass raise return try while with yield match case`),
types: wordSet(`int str float bool list dict set tuple bytes object`),
consts: wordSet(`True False None`),
lineComments: []string{"#"},
quotes: "\"'", tripleQuotes: true,
}
shSpec = &langSpec{
keywords: wordSet(`if then else elif fi for while until do done case esac in function select time
return exit break continue export local readonly declare unset source alias`),
types: wordSet(`echo cd ls cat grep sed awk find git go make docker curl mkdir rm cp mv chmod chown
touch sudo npm pip python gofmt`),
lineComments: []string{"#"},
quotes: "\"'", shellVars: true,
}
jsonSpec = &langSpec{
consts: wordSet(`true false null`),
quotes: `"`, jsonKeys: true,
}
sqlSpec = &langSpec{
keywords: wordSet(`select from where insert into values update set delete create table alter drop index
join left right inner outer full cross on group by order having limit offset as and or not in is
like between exists union all distinct case when then else end primary key foreign references
default unique constraint with returning begin commit rollback extension if`),
types: wordSet(`int integer bigint smallint serial bigserial text varchar char boolean bool date
timestamp timestamptz numeric decimal float double real jsonb json uuid vector`),
consts: wordSet(`true false null`),
lineComments: []string{"--"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'", foldCase: true,
}
cSpec = &langSpec{
keywords: wordSet(`auto break case const continue default do else enum extern for goto if inline
register return sizeof static struct switch typedef union volatile while class namespace
template typename public private protected virtual new delete using try catch throw`),
types: wordSet(`void char short int long float double signed unsigned bool size_t int8_t int16_t
int32_t int64_t uint8_t uint16_t uint32_t uint64_t string`),
consts: wordSet(`true false NULL nullptr`),
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'",
}
javaSpec = &langSpec{
keywords: wordSet(`abstract assert break case catch class const continue default do else enum extends
final finally for if implements import instanceof interface native new package private protected
public return static super switch synchronized this throw throws try void volatile while`),
types: wordSet(`boolean byte char short int long float double String Object List Map Set`),
consts: wordSet(`true false null`), lineComments: []string{"//"},
blockStart: "/*", blockEnd: "*/", quotes: "\"'",
}
// No ' in quotes: Rust lifetimes ('a) would swallow the rest of the line.
rustSpec = &langSpec{
keywords: wordSet(`as async await break const continue crate dyn else enum extern fn for if impl in
let loop match mod move mut pub ref return self Self static struct super trait type unsafe use
where while`),
types: wordSet(`i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String
Vec Option Result Box`),
consts: wordSet(`true false None Some Ok Err`), lineComments: []string{"//"},
blockStart: "/*", blockEnd: "*/", quotes: `"`,
}
langByName = map[string]*langSpec{
"go": goSpec, "golang": goSpec,
"js": jsSpec, "javascript": jsSpec, "jsx": jsSpec, "ts": jsSpec, "typescript": jsSpec, "tsx": jsSpec,
"py": pySpec, "python": pySpec,
"sh": shSpec, "bash": shSpec, "shell": shSpec, "zsh": shSpec, "console": shSpec,
"json": jsonSpec,
"sql": sqlSpec, "postgres": sqlSpec, "psql": sqlSpec,
"c": cSpec, "cpp": cSpec, "c++": cSpec, "cc": cSpec, "h": cSpec, "hpp": cSpec,
"java": javaSpec,
"rust": rustSpec, "rs": rustSpec,
}
)
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isIdentStart(c byte) bool { return c == '_' || c == '$' || (c|0x20 >= 'a' && c|0x20 <= 'z') }
func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) }
func spanned(b *strings.Builder, class, text string) {
if class == "" {
b.WriteString(stdhtml.EscapeString(text))
return
}
b.WriteString(`<span class="tok-`)
b.WriteString(class)
b.WriteString(`">`)
b.WriteString(stdhtml.EscapeString(text))
b.WriteString(`</span>`)
}
// highlight takes a fenced block's language and its HTML-escaped body and
// returns the body with token spans. Unknown languages come back unchanged.
func highlight(lang, escapedBody string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
if len(escapedBody) > maxHighlightBytes {
return escapedBody
}
if lang == "diff" || lang == "patch" {
return highlightDiff(stdhtml.UnescapeString(escapedBody))
}
spec := langByName[lang]
if spec == nil {
return escapedBody
}
return spec.lex(stdhtml.UnescapeString(escapedBody))
}
func highlightDiff(src string) string {
var b strings.Builder
lines := strings.Split(src, "\n")
for i, ln := range lines {
class := ""
switch {
case strings.HasPrefix(ln, "+++") || strings.HasPrefix(ln, "---"):
class = "hunk"
case strings.HasPrefix(ln, "@@"):
class = "hunk"
case strings.HasPrefix(ln, "+"):
class = "add"
case strings.HasPrefix(ln, "-"):
class = "del"
}
spanned(&b, class, ln)
if i < len(lines)-1 {
b.WriteByte('\n')
}
}
return b.String()
}
func (s *langSpec) lex(src string) string {
var b strings.Builder
b.Grow(len(src) + len(src)/2)
i := 0
for i < len(src) {
c := src[i]
rest := src[i:]
if n := s.commentLen(src, i); n > 0 {
spanned(&b, "cmt", rest[:n])
i += n
continue
}
if strings.IndexByte(s.quotes, c) >= 0 {
n := s.stringLen(rest)
class := "str"
if s.jsonKeys {
k := i + n
for k < len(src) && (src[k] == ' ' || src[k] == '\t') {
k++
}
if k < len(src) && src[k] == ':' {
class = "key"
}
}
spanned(&b, class, rest[:n])
i += n
continue
}
if isDigit(c) && (i == 0 || !isIdent(src[i-1])) {
j := i + 1
for j < len(src) && (isIdent(src[j]) || (src[j] == '.' && j+1 < len(src) && isDigit(src[j+1]))) {
j++
}
spanned(&b, "num", src[i:j])
i = j
continue
}
if s.shellVars && c == '$' && i+1 < len(src) && (isIdentStart(src[i+1]) || src[i+1] == '{') {
j := i + 1
if src[j] == '{' {
if k := strings.IndexAny(src[j:], "}\n"); k > 0 && src[j+k] == '}' {
j += k + 1
} else {
j++
}
} else {
for j < len(src) && isIdent(src[j]) {
j++
}
}
spanned(&b, "var", src[i:j])
i = j
continue
}
if isIdentStart(c) {
j := i + 1
for j < len(src) && isIdent(src[j]) {
j++
}
spanned(&b, s.classify(src[i:j], src[j:]), src[i:j])
i = j
continue
}
_, size := utf8.DecodeRuneInString(rest)
spanned(&b, "", rest[:size])
i += size
}
return b.String()
}
func (s *langSpec) classify(word, after string) string {
w := word
if s.foldCase {
w = strings.ToLower(word)
}
switch {
case s.keywords[w]:
return "kw"
case s.types[w]:
return "type"
case s.consts[w]:
return "const"
}
if len(s.types) > 0 || len(s.keywords) > 0 { // JSON has no functions
for k := 0; k < len(after); k++ {
if after[k] == ' ' || after[k] == '\t' {
continue
}
if after[k] == '(' {
return "fn"
}
break
}
}
return ""
}
// commentLen returns the length of a comment starting at src[i], or 0.
func (s *langSpec) commentLen(src string, i int) int {
rest := src[i:]
for _, lc := range s.lineComments {
if !strings.HasPrefix(rest, lc) {
continue
}
// A "#" only starts a comment at a word boundary, so "a#b" and
// "$#" aren't swallowed.
if lc == "#" && i > 0 && src[i-1] != ' ' && src[i-1] != '\t' && src[i-1] != '\n' && src[i-1] != ';' {
continue
}
if k := strings.IndexByte(rest, '\n'); k >= 0 {
return k
}
return len(rest)
}
if s.blockStart != "" && strings.HasPrefix(rest, s.blockStart) {
if k := strings.Index(rest[len(s.blockStart):], s.blockEnd); k >= 0 {
return len(s.blockStart) + k + len(s.blockEnd)
}
return len(rest)
}
return 0
}
// stringLen returns the length of the string literal starting at rest[0].
// Unterminated ' and " literals end at the newline, so one stray quote
// can't colour the rest of the block.
func (s *langSpec) stringLen(rest string) int {
q := rest[0]
if s.tripleQuotes {
if t := strings.Repeat(string(q), 3); strings.HasPrefix(rest, t) {
if k := strings.Index(rest[3:], t); k >= 0 {
return 3 + k + 3
}
return len(rest)
}
}
if q == '`' { // raw / multi-line
if k := strings.IndexByte(rest[1:], '`'); k >= 0 {
return 1 + k + 1
}
return len(rest)
}
j := 1
for j < len(rest) {
switch rest[j] {
case '\\':
j += 2
continue
case q:
return j + 1
case '\n':
return j
}
j++
}
if j > len(rest) {
j = len(rest)
}
return j
}

View File

@@ -0,0 +1,81 @@
package renderkit
import (
stdhtml "html"
"regexp"
"strings"
"testing"
)
var tagRe = regexp.MustCompile(`<[^>]*>`)
// visibleText strips the span markup and unescapes, i.e. what the user
// sees and what the copy button copies.
func visibleText(s string) string { return stdhtml.UnescapeString(tagRe.ReplaceAllString(s, "")) }
func TestHighlightPreservesText(t *testing.T) {
samples := map[string]string{
"go": "package main\n\n// hi\nfunc main() {\n\tx := \"a<b>\" + `raw\nline`\n\tif x != nil { return 42 }\n}\n",
"py": "def f(x):\n \"\"\"doc\"\"\"\n return x # c\n",
"bash": "echo \"$HOME\" ${A} | grep 'x' # note\nfoo#bar $#\n",
"json": "{\"a\": [1, 2.5, true, null], \"b\": \"x\"}",
"sql": "SELECT count(*) FROM t WHERE a = 'x' -- c\n",
"rust": "fn f<'a>(x: &'a str) -> u32 { 1 }\n",
"diff": "@@ -1 +1 @@\n-old\n+new\n ctx\n",
}
for lang, src := range samples {
out := highlight(lang, stdhtml.EscapeString(src))
if got := visibleText(out); got != src {
t.Errorf("%s: text changed\nwant %q\ngot %q", lang, src, got)
}
if !strings.Contains(out, "<span class=\"tok-") {
t.Errorf("%s: nothing was highlighted: %s", lang, out)
}
}
}
func TestHighlightTokens(t *testing.T) {
out := highlight("go", stdhtml.EscapeString("func main() { return \"a<b>\" } // done"))
for _, want := range []string{
`<span class="tok-kw">func</span>`,
`<span class="tok-fn">main</span>`,
`<span class="tok-kw">return</span>`,
`<span class="tok-str">&#34;a&lt;b&gt;&#34;</span>`,
`<span class="tok-cmt">// done</span>`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing %s in %s", want, out)
}
}
if !strings.Contains(highlight("json", `&#34;k&#34;: 1`), `tok-key`) {
t.Error("json key not detected")
}
if !strings.Contains(highlight("sql", "select 1"), `<span class="tok-kw">select</span>`) {
t.Error("sql keywords should be case-insensitive")
}
}
func TestHighlightNeverInjectsMarkup(t *testing.T) {
evil := "x := \"</span><script>alert(1)</script>\" // <img src=x onerror=1>"
out := highlight("go", stdhtml.EscapeString(evil))
if strings.Contains(out, "<script") || strings.Contains(out, "<img") {
t.Fatalf("markup leaked: %s", out)
}
}
func TestHighlightUnknownLanguageUnchanged(t *testing.T) {
in := "a &lt; b"
if got := highlight("brainfuck", in); got != in {
t.Fatalf("got %q", got)
}
if got := highlight("", in); got != in {
t.Fatalf("got %q", got)
}
}
func TestMarkdownHighlightsFencedCode(t *testing.T) {
out := Markdown("```go\nfunc main() {}\n```")
if !strings.Contains(out, `<span class="tok-kw">func</span>`) || !strings.Contains(out, "copy-btn") {
t.Fatalf("not highlighted: %s", out)
}
}

94
pkg/renderkit/markdown.go Normal file
View File

@@ -0,0 +1,94 @@
// Package render turns raw assistant text into safe HTML: Markdown for
// finished messages, and simple HTML-escaping (with <br> line breaks)
// for the plain-text tokens shown while a reply is still streaming.
package renderkit
import (
stdhtml "html"
"regexp"
"strings"
"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
mdparser "github.com/gomarkdown/markdown/parser"
)
// gomarkdown's Parser and Renderer are single-use and stateful ("Parser is
// not reusable" — a second Parse on the same instance panics, and sharing
// one across goroutines would race), so both are built fresh per call.
const (
mdParserExtensions = mdparser.CommonExtensions | mdparser.AutoHeadingIDs | mdparser.FencedCode
mdRendererFlags = mdhtml.CommonFlags | mdhtml.HrefTargetBlank | mdhtml.SkipHTML | mdhtml.Safelink
)
// fencedCodeBlock matches gomarkdown's output for a fenced code block:
// <pre><code class="language-xxx">...escaped content...</code></pre>
// (the language class is only present when the fence specified one,
// e.g. ```go). (?s) lets "." match newlines, since code blocks are
// almost always multi-line.
var fencedCodeBlock = regexp.MustCompile(`(?s)<pre><code(?: class="language-([\w+.-]*)")?>(.*?)</code></pre>`)
// Markdown renders a complete Markdown document (the agent's finished
// reply) to HTML for embedding in a chat bubble.
//
// The text being rendered is model output, and the model reads files
// and command output that may contain attacker-controlled text (prompt
// injection), so it is treated as untrusted: raw HTML in the Markdown
// source is dropped (SkipHTML) and only safe link schemes are linked
// (Safelink — no javascript: URLs). Code spans and fenced blocks are
// still HTML-escaped by gomarkdown, so showing "<div>" in code works.
//
// Fenced code blocks get an extra post-processing pass (addCodeBlockChrome)
// to wrap them in a header bar with a language label and copy button,
// matching the rest of the UI's terminal-chat styling — done as plain
// string manipulation on gomarkdown's already-escaped output rather
// than pulling in a syntax-highlighting dependency (see the module's
// deliberately small go.mod). Keywords, strings, comments, numbers and so
// on are coloured by the small built-in lexer in highlight.go.
func Markdown(src string) string {
return addCodeBlockChrome(rawMarkdown(src))
}
// rawMarkdown is gomarkdown's output before addCodeBlockChrome. Split out so
// Snippets (snippets.go) can enumerate fenced blocks in exactly the same
// order Markdown renders them.
func rawMarkdown(src string) string {
p := mdparser.NewWithExtensions(mdParserExtensions)
r := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdRendererFlags})
return string(markdown.ToHTML([]byte(src), p, r))
}
// addCodeBlockChrome wraps every fenced code block in a `.code-block`
// container with a header (language label + copy button) so it reads
// like a small file viewer instead of a bare <pre>. The copy button has
// no inline JS handler — web/static/js/app.js listens for clicks on
// `.copy-btn` via event delegation and reads the code from a sibling
// <pre>, so this still degrades gracefully (the button just does
// nothing) if that script is blocked, matching the rest of the app's
// zero-required-JS posture.
func addCodeBlockChrome(html string) string {
return fencedCodeBlock.ReplaceAllStringFunc(html, func(block string) string {
m := fencedCodeBlock.FindStringSubmatch(block)
lang := m[1]
body := m[2]
label := lang
if label == "" {
label = "text"
}
return `<div class="code-block">` +
`<div class="code-block-header">` +
`<span class="code-block-lang">` + stdhtml.EscapeString(label) + `</span>` +
`<button type="button" class="copy-btn" aria-label="Copy code">Copy</button>` +
`</div>` +
`<pre><code class="language-` + stdhtml.EscapeString(label) + `">` + highlight(lang, body) + `</code></pre>` +
`</div>`
})
}
// PlainStream escapes raw text and converts newlines to <br>, for
// rendering the in-progress streamed response before the final Markdown
// pass runs on the "done" event.
func PlainStream(src string) string {
escaped := stdhtml.EscapeString(src)
return strings.ReplaceAll(escaped, "\n", "<br>")
}

View File

@@ -0,0 +1,49 @@
package renderkit
import (
"strings"
"testing"
)
func TestMarkdownDropsRawHTMLAndUnsafeLinks(t *testing.T) {
out := Markdown("hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))\n\n<div onclick=\"x()\">boom</div>")
for _, bad := range []string{"<script", "<img", "onerror", "onclick", `href="javascript:`} {
if strings.Contains(out, bad) {
t.Fatalf("output contains %q:\n%s", bad, out)
}
}
}
func TestMarkdownKeepsCodeAndFormatting(t *testing.T) {
out := Markdown("**bold**\n\n```go\nx := \"<div>\" + `a`\n```\n\n`<b>`")
for _, want := range []string{"<strong>bold</strong>", "code-block", "copy-btn", "&lt;div&gt;", "&lt;b&gt;"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestPlainStreamEscapes(t *testing.T) {
if got := PlainStream("<b>a</b>\nb"); got != "&lt;b&gt;a&lt;/b&gt;<br>b" {
t.Fatal(got)
}
}
// Regression: gomarkdown's Parser panics if reused, which used to make every
// reply after the first fail. Also exercised concurrently for the race detector.
func TestMarkdownIsReusableAndConcurrencySafe(t *testing.T) {
done := make(chan struct{})
for i := 0; i < 8; i++ {
go func() {
defer func() { done <- struct{}{} }()
for j := 0; j < 20; j++ {
if out := Markdown("# title\n\ntext"); !strings.Contains(out, "<h1") {
t.Error(out)
}
}
}()
}
for i := 0; i < 8; i++ {
<-done
}
}

76
pkg/renderkit/snippets.go Normal file
View File

@@ -0,0 +1,76 @@
package renderkit
import (
stdhtml "html"
"path"
"regexp"
"strings"
)
// Snippet describes one fenced code block in an assistant reply that is
// worth offering as a download.
type Snippet struct {
Index int // position among ALL fenced blocks in the reply (matches the DOM order of .code-block)
Lang string // lower-cased fence language
Ext string // file extension including the dot, e.g. ".go"
Label string // display name of the type, e.g. "Go"
Lines int
Bytes int
// Filename is set when the block's first line is a comment naming a
// file with the matching extension (e.g. "// internal/db/users.go"),
// which models often write; only the base name is kept.
Filename string
}
var firstLineFilename = regexp.MustCompile(`^\s*(?://|#|--|/\*|<!--)\s*([\w./-]+\.\w+)\s*(?:\*/|-->)?\s*$`)
func filenameHint(body, ext string) string {
first, _, _ := strings.Cut(body, "\n")
m := firstLineFilename.FindStringSubmatch(first)
if m == nil {
return ""
}
base := path.Base(m[1])
if path.Ext(base) != ext || base == ext {
return ""
}
return base
}
type snippetType struct{ ext, label string }
var snippetTypes = map[string]snippetType{
"go": {".go", "Go"}, "golang": {".go", "Go"},
"js": {".js", "JavaScript"}, "javascript": {".js", "JavaScript"},
"jsx": {".jsx", "JSX"}, "ts": {".ts", "TypeScript"}, "typescript": {".ts", "TypeScript"}, "tsx": {".tsx", "TSX"},
"py": {".py", "Python"}, "python": {".py", "Python"},
"sh": {".sh", "Shell"}, "bash": {".sh", "Shell"}, "shell": {".sh", "Shell"}, "zsh": {".sh", "Shell"},
"json": {".json", "JSON"}, "sql": {".sql", "SQL"},
"c": {".c", "C"}, "h": {".h", "C header"}, "cpp": {".cpp", "C++"}, "c++": {".cpp", "C++"},
"java": {".java", "Java"}, "rust": {".rs", "Rust"}, "rs": {".rs", "Rust"},
"diff": {".patch", "Patch"}, "patch": {".patch", "Patch"},
"yaml": {".yml", "YAML"}, "yml": {".yml", "YAML"}, "toml": {".toml", "TOML"},
"html": {".html", "HTML"}, "css": {".css", "CSS"},
"md": {".md", "Markdown"}, "markdown": {".md", "Markdown"},
}
// Snippets lists the fenced code blocks in src that have a recognised
// language, in the order Markdown renders them. Blocks with no language (or
// an unknown one) are skipped, but still counted in Index.
func Snippets(src string) []Snippet {
var out []Snippet
for i, m := range fencedCodeBlock.FindAllStringSubmatch(rawMarkdown(src), -1) {
lang := strings.ToLower(m[1])
t, ok := snippetTypes[lang]
if !ok {
continue
}
body := stdhtml.UnescapeString(m[2])
out = append(out, Snippet{
Index: i, Lang: lang, Ext: t.ext, Label: t.label,
Lines: strings.Count(strings.TrimRight(body, "\n"), "\n") + 1,
Bytes: len(body), Filename: filenameHint(body, t.ext),
})
}
return out
}

View File

@@ -0,0 +1,34 @@
package renderkit
import "testing"
func TestSnippets(t *testing.T) {
src := "intro\n\n```\nno lang\n```\n\n```go\npackage main\n\nfunc main() {}\n```\n\n```python\nprint(1)\n```\n\n```weird\nx\n```\n"
got := Snippets(src)
if len(got) != 2 {
t.Fatalf("want 2 snippets, got %+v", got)
}
if got[0].Index != 1 || got[0].Ext != ".go" || got[0].Lines != 3 {
t.Errorf("go snippet wrong: %+v", got[0])
}
if got[1].Index != 2 || got[1].Ext != ".py" || got[1].Label != "Python" {
t.Errorf("python snippet wrong: %+v", got[1])
}
}
func TestSnippetFilenameHint(t *testing.T) {
src := "```go\n// internal/db/users.go\npackage db\n\nvar X = 1\n```\n\n```go\n// just a comment\npackage x\n\nvar Y = 2\n```\n\n```python\n# notes.txt\nprint(1)\nprint(2)\n```\n"
got := Snippets(src)
if len(got) != 3 {
t.Fatalf("want 3, got %+v", got)
}
if got[0].Filename != "users.go" {
t.Errorf("want users.go, got %q", got[0].Filename)
}
if got[1].Filename != "" {
t.Errorf("plain comment must not become a filename: %q", got[1].Filename)
}
if got[2].Filename != "" {
t.Errorf("extension mismatch (.txt vs .py) must be ignored: %q", got[2].Filename)
}
}