a73x

64314aee

feat(scanner): support multi-language comment syntax

a73x   2026-04-29 05:44

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

diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index dc41796..5da726c 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -15,9 +15,31 @@ type Marker struct {
	UUID   string
}

// commentPrefixes maps file extension (lowercase, with dot) to the line-comment prefix.
var commentPrefixes = map[string]string{
	".go": "//",
// lineCommentPrefixes: extensions whose markers must be a line-comment.
var lineCommentPrefixes = map[string]string{
	".go": "//", ".js": "//", ".jsx": "//", ".ts": "//", ".tsx": "//",
	".c": "//", ".cpp": "//", ".h": "//", ".hpp": "//",
	".rs": "//", ".java": "//", ".kt": "//", ".swift": "//",
	".scala": "//", ".cs": "//",
	".py": "#", ".rb": "#", ".sh": "#", ".bash": "#", ".zsh": "#",
	".yaml": "#", ".yml": "#", ".toml": "#",
	".sql": "--", ".lua": "--", ".hs": "--", ".elm": "--",
}

// blockCommentDelims: extensions whose single-line markers may use a block comment.
type blockDelim struct{ open, close string }

var blockCommentDelims = map[string]blockDelim{
	".css":  {"/*", "*/"},
	".html": {"<!--", "-->"},
	".xml":  {"<!--", "-->"},
	".md":   {"<!--", "-->"},
}

// nameOverrides: filename → line-comment prefix when no useful extension exists.
var nameOverrides = map[string]string{
	"Makefile": "#", "makefile": "#",
	"Dockerfile": "#", "dockerfile": "#",
}

var (
@@ -27,10 +49,15 @@ var (

func Scan(path string) ([]Marker, error) {
	ext := strings.ToLower(filepath.Ext(path))
	prefix, ok := commentPrefixes[ext]
	if !ok {
	linePrefix, hasLine := lineCommentPrefixes[ext]
	if !hasLine {
		linePrefix, hasLine = nameOverrides[filepath.Base(path)]
	}
	blockD, hasBlock := blockCommentDelims[ext]
	if !hasLine && !hasBlock {
		return nil, nil
	}

	f, err := os.Open(path)
	if err != nil {
		return nil, err
@@ -44,10 +71,15 @@ func Scan(path string) ([]Marker, error) {
	for sc.Scan() {
		line++
		raw := strings.TrimSpace(sc.Text())
		if !strings.HasPrefix(raw, prefix) {
		var body string
		switch {
		case hasLine && strings.HasPrefix(raw, linePrefix):
			body = strings.TrimSpace(strings.TrimPrefix(raw, linePrefix))
		case hasBlock && strings.HasPrefix(raw, blockD.open) && strings.HasSuffix(raw, blockD.close):
			body = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, blockD.open), blockD.close))
		default:
			continue
		}
		body := strings.TrimSpace(strings.TrimPrefix(raw, prefix))
		if m := taggedRE.FindStringSubmatch(body); m != nil {
			markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]})
			continue
diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go
index a097963..1b08be2 100644
--- a/internal/scanner/scanner_test.go
+++ b/internal/scanner/scanner_test.go
@@ -37,3 +37,75 @@ func TestScanGoFileSingleUntaggedMarker(t *testing.T) {
		t.Errorf("Tagged = true, want false")
	}
}

func TestScanPythonFile(t *testing.T) {
	dir := t.TempDir()
	path := writeFile(t, dir, "foo.py", "# @claude rename this\ndef foo(): pass\n")
	markers, err := Scan(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(markers) != 1 || markers[0].Line != 1 || markers[0].Text != "@claude rename this" {
		t.Fatalf("got %#v", markers)
	}
}

func TestScanSQLFile(t *testing.T) {
	dir := t.TempDir()
	path := writeFile(t, dir, "q.sql", "-- @claude add an index\nSELECT 1;\n")
	markers, err := Scan(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(markers) != 1 || markers[0].Text != "@claude add an index" {
		t.Fatalf("got %#v", markers)
	}
}

func TestScanCSSBlockComment(t *testing.T) {
	dir := t.TempDir()
	path := writeFile(t, dir, "s.css", "/* @claude make this responsive */\nbody {}\n")
	markers, err := Scan(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(markers) != 1 || markers[0].Text != "@claude make this responsive" {
		t.Fatalf("got %#v", markers)
	}
}

func TestScanHTMLBlockComment(t *testing.T) {
	dir := t.TempDir()
	path := writeFile(t, dir, "x.html", "<!-- @claude add aria labels -->\n<div></div>\n")
	markers, err := Scan(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(markers) != 1 || markers[0].Text != "@claude add aria labels" {
		t.Fatalf("got %#v", markers)
	}
}

func TestScanUnrecognizedExtensionSkipped(t *testing.T) {
	dir := t.TempDir()
	path := writeFile(t, dir, "weird.xyz", "// @claude do thing\n")
	markers, err := Scan(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(markers) != 0 {
		t.Fatalf("expected skip, got %#v", markers)
	}
}

func TestScanMakefileByName(t *testing.T) {
	dir := t.TempDir()
	path := writeFile(t, dir, "Makefile", "# @claude add a lint target\nbuild:\n\techo hi\n")
	markers, err := Scan(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(markers) != 1 || markers[0].Text != "@claude add a lint target" {
		t.Fatalf("got %#v", markers)
	}
}