64314aee
feat(scanner): support multi-language comment syntax
a73x 2026-04-29 05:44
Commit message
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 | } | ||