a73x

64314aee

feat(scanner): support multi-language comment syntax

a73x   2026-04-29 05:44

Commit message
feat(scanner): support multi-language comment syntax

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

internal/scanner/scanner.go
Old New
@@ -15,9 +15,31 @@ type Marker struct {
15 UUID string 15 UUID string
16 } 16 }
17 17
18 // commentPrefixes maps file extension (lowercase, with dot) to the line-comment prefix. 18 // lineCommentPrefixes: extensions whose markers must be a line-comment.
19 var commentPrefixes = map[string]string{ 19 var lineCommentPrefixes = map[string]string{
20 ".go": "//", 20 ".go": "//", ".js": "//", ".jsx": "//", ".ts": "//", ".tsx": "//",
21 ".c": "//", ".cpp": "//", ".h": "//", ".hpp": "//",
22 ".rs": "//", ".java": "//", ".kt": "//", ".swift": "//",
23 ".scala": "//", ".cs": "//",
24 ".py": "#", ".rb": "#", ".sh": "#", ".bash": "#", ".zsh": "#",
25 ".yaml": "#", ".yml": "#", ".toml": "#",
26 ".sql": "--", ".lua": "--", ".hs": "--", ".elm": "--",
27 }
28
29 // blockCommentDelims: extensions whose single-line markers may use a block comment.
30 type blockDelim struct{ open, close string }
31
32 var blockCommentDelims = map[string]blockDelim{
33 ".css": {"/*", "*/"},
34 ".html": {"<!--", "-->"},
35 ".xml": {"<!--", "-->"},
36 ".md": {"<!--", "-->"},
37 }
38
39 // nameOverrides: filename → line-comment prefix when no useful extension exists.
40 var nameOverrides = map[string]string{
41 "Makefile": "#", "makefile": "#",
42 "Dockerfile": "#", "dockerfile": "#",
21 } 43 }
22 44
23 var ( 45 var (
@@ -27,10 +49,15 @@ var (
27 49
28 func Scan(path string) ([]Marker, error) { 50 func Scan(path string) ([]Marker, error) {
29 ext := strings.ToLower(filepath.Ext(path)) 51 ext := strings.ToLower(filepath.Ext(path))
30 prefix, ok := commentPrefixes[ext] 52 linePrefix, hasLine := lineCommentPrefixes[ext]
31 if !ok { 53 if !hasLine {
54 linePrefix, hasLine = nameOverrides[filepath.Base(path)]
55 }
56 blockD, hasBlock := blockCommentDelims[ext]
57 if !hasLine && !hasBlock {
32 return nil, nil 58 return nil, nil
33 } 59 }
60
34 f, err := os.Open(path) 61 f, err := os.Open(path)
35 if err != nil { 62 if err != nil {
36 return nil, err 63 return nil, err
@@ -44,10 +71,15 @@ func Scan(path string) ([]Marker, error) {
44 for sc.Scan() { 71 for sc.Scan() {
45 line++ 72 line++
46 raw := strings.TrimSpace(sc.Text()) 73 raw := strings.TrimSpace(sc.Text())
47 if !strings.HasPrefix(raw, prefix) { 74 var body string
75 switch {
76 case hasLine && strings.HasPrefix(raw, linePrefix):
77 body = strings.TrimSpace(strings.TrimPrefix(raw, linePrefix))
78 case hasBlock && strings.HasPrefix(raw, blockD.open) && strings.HasSuffix(raw, blockD.close):
79 body = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, blockD.open), blockD.close))
80 default:
48 continue 81 continue
49 } 82 }
50 body := strings.TrimSpace(strings.TrimPrefix(raw, prefix))
51 if m := taggedRE.FindStringSubmatch(body); m != nil { 83 if m := taggedRE.FindStringSubmatch(body); m != nil {
52 markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]}) 84 markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]})
53 continue 85 continue
internal/scanner/scanner_test.go
Old New
@@ -37,3 +37,75 @@ func TestScanGoFileSingleUntaggedMarker(t *testing.T) {
37 t.Errorf("Tagged = true, want false") 37 t.Errorf("Tagged = true, want false")
38 } 38 }
39 } 39 }
40
41 func TestScanPythonFile(t *testing.T) {
42 dir := t.TempDir()
43 path := writeFile(t, dir, "foo.py", "# @claude rename this\ndef foo(): pass\n")
44 markers, err := Scan(path)
45 if err != nil {
46 t.Fatal(err)
47 }
48 if len(markers) != 1 || markers[0].Line != 1 || markers[0].Text != "@claude rename this" {
49 t.Fatalf("got %#v", markers)
50 }
51 }
52
53 func TestScanSQLFile(t *testing.T) {
54 dir := t.TempDir()
55 path := writeFile(t, dir, "q.sql", "-- @claude add an index\nSELECT 1;\n")
56 markers, err := Scan(path)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if len(markers) != 1 || markers[0].Text != "@claude add an index" {
61 t.Fatalf("got %#v", markers)
62 }
63 }
64
65 func TestScanCSSBlockComment(t *testing.T) {
66 dir := t.TempDir()
67 path := writeFile(t, dir, "s.css", "/* @claude make this responsive */\nbody {}\n")
68 markers, err := Scan(path)
69 if err != nil {
70 t.Fatal(err)
71 }
72 if len(markers) != 1 || markers[0].Text != "@claude make this responsive" {
73 t.Fatalf("got %#v", markers)
74 }
75 }
76
77 func TestScanHTMLBlockComment(t *testing.T) {
78 dir := t.TempDir()
79 path := writeFile(t, dir, "x.html", "<!-- @claude add aria labels -->\n<div></div>\n")
80 markers, err := Scan(path)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if len(markers) != 1 || markers[0].Text != "@claude add aria labels" {
85 t.Fatalf("got %#v", markers)
86 }
87 }
88
89 func TestScanUnrecognizedExtensionSkipped(t *testing.T) {
90 dir := t.TempDir()
91 path := writeFile(t, dir, "weird.xyz", "// @claude do thing\n")
92 markers, err := Scan(path)
93 if err != nil {
94 t.Fatal(err)
95 }
96 if len(markers) != 0 {
97 t.Fatalf("expected skip, got %#v", markers)
98 }
99 }
100
101 func TestScanMakefileByName(t *testing.T) {
102 dir := t.TempDir()
103 path := writeFile(t, dir, "Makefile", "# @claude add a lint target\nbuild:\n\techo hi\n")
104 markers, err := Scan(path)
105 if err != nil {
106 t.Fatal(err)
107 }
108 if len(markers) != 1 || markers[0].Text != "@claude add a lint target" {
109 t.Fatalf("got %#v", markers)
110 }
111 }