diff --git a/CHANGELOG.md b/CHANGELOG.md index 178ddc7..a5dd5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ All notable changes to cymbal are documented here. - **OpenCode plugins now surface update notices through native OS notifications** — when a newer cymbal version is available, the OpenCode plugin shows a platform-native notification (macOS Notification Center via `osascript`, Linux via `notify-send`, Windows via PowerShell) so users see updates regardless of TUI or Desktop mode. Respects `CYMBAL_NO_UPDATE_NOTIFIER` and cymbal's per-version notification throttle. ([#23](https://github.com/1broseidon/cymbal/issues/23)) - **New `cymbal hook notify` command** — emits a structured JSON payload with update availability, version, and install command for agent plugins that want to surface update notices outside hidden system context. Supports `--format=json|text` and `--update=cache|if-stale`. +### Changed + +- **Bumped Go toolchain floor to 1.26.3** — resolves 10 stdlib vulnerabilities reported by `govulncheck`, including callable traces in `net` and `net/http` reached from the update notifier. CI uses `go-version-file: go.mod`; local builds with Go 1.21+ auto-fetch the new toolchain. ([#54](https://github.com/1broseidon/cymbal/pull/54)) + +### Fixed + +- **`cymbal investigate` no longer leaks references across languages** — when a name resolves to a single symbol, refs and transitive impact are filtered to the resolved symbol's language, so investigating a Go `App` struct in a polyglot repo no longer returns call sites from a TSX function with the same name. `cymbal refs` keeps its documented best-effort name-only behavior. +- **`.tsx` files are now parsed with the TSX grammar** — anonymous arrow functions inside JSX props (`onClick={async () => ...}`) were being indexed as phantom `method async` symbols because the plain TypeScript grammar can't see JSX boundaries. `.tsx` is now its own language entry using `tree-sitter-typescript`'s TSX grammar; `.ts`/`.mts`/`.cts` continue to use the TypeScript grammar. +- **Swift field and property accesses now record references** — `cymbal refs ` previously returned zero hits because `extractRefSwift` only handled call expressions and named type uses. Member access through `navigation_expression` (e.g. `self.field`, `field.method()`, `self.a.b = x`) now produces refs. `cymbal refs` is still best-effort and name-only. +- **TypeScript `export function` declarations now retain their signature** — exported functions and `export const fn = (x) => …` arrow forms were emitting an empty `signature` field because signature extraction looked for parameters on the outer wrapper node. The extractor now descends through `export_statement` and `lexical_declaration` wrappers before reading parameters and return type. +- **Swift symbol ranges anchor at the keyword, not at preceding attributes** — protocols / classes / functions decorated with `@MainActor`, `@objc`, `public`, etc. previously had their start line absorbed into the leading attributes block, so `cymbal show` and `cymbal outline` reported a range one line above the actual `protocol` / `class` / `func` keyword. + ## [0.13.1] - 2026-05-06 ### Changed diff --git a/index/index.go b/index/index.go index 7fbf5d0..cdbb772 100644 --- a/index/index.go +++ b/index/index.go @@ -939,14 +939,14 @@ func Investigate(dbPath, symbolName string, opts ...InvestigateOpts) (*Investiga switch sym.Kind { case "function", "method": res.Kind = "function" - res.Refs, _ = store.FindReferences(sym.Name, 20) - res.Impact, _ = store.FindImpact(sym.Name, 2, 20) + res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20) + res.Impact, _ = store.FindImpactScoped(sym.Name, sym.Language, 2, 20) case "class", "struct", "type", "interface", "trait", "enum", "object", "mixin", "extension", "protocol", "record", "actor": res.Kind = "type" res.Members, _ = store.ChildSymbols(sym.Name, 50, sym.File) // For types, show who references the type name. - res.Refs, _ = store.FindReferences(sym.Name, 20) + res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20) // Inheritance / conformance edges (both directions, best-effort). res.Implementors, _ = store.FindImplementors(sym.Name, 20) res.Implements, _ = store.FindImplements(sym.Name, 20) @@ -954,7 +954,7 @@ func Investigate(dbPath, symbolName string, opts ...InvestigateOpts) (*Investiga default: // Unknown kind — return source + refs as best effort. res.Kind = sym.Kind - res.Refs, _ = store.FindReferences(sym.Name, 20) + res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20) } return res, nil @@ -993,17 +993,17 @@ func InvestigateResolved(dbPath string, sym SymbolResult) (*InvestigateResult, e switch sym.Kind { case "function", "method": res.Kind = "function" - res.Refs, _ = store.FindReferences(sym.Name, 20) - res.Impact, _ = store.FindImpact(sym.Name, 2, 20) + res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20) + res.Impact, _ = store.FindImpactScoped(sym.Name, sym.Language, 2, 20) case "class", "struct", "type", "interface", "trait", "enum", "object", "mixin", "extension", "protocol", "record", "actor": res.Kind = "type" res.Members, _ = store.ChildSymbols(sym.Name, 50, sym.File) - res.Refs, _ = store.FindReferences(sym.Name, 20) + res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20) res.Implementors, _ = store.FindImplementors(sym.Name, 20) res.Implements, _ = store.FindImplements(sym.Name, 20) default: res.Kind = sym.Kind - res.Refs, _ = store.FindReferences(sym.Name, 20) + res.Refs, _ = store.FindReferencesScoped(sym.Name, sym.Language, 20) } return res, nil diff --git a/index/index_phase3_test.go b/index/index_phase3_test.go index e80ed71..efb31d4 100644 --- a/index/index_phase3_test.go +++ b/index/index_phase3_test.go @@ -466,3 +466,83 @@ func implementorsContain(results []ImplementorResult, implementer, target string } return false } + +// Same-name symbols across languages must not pollute each other's refs. +// Before the FindReferencesScoped fix, `investigate App` on the Go struct +// returned the TSX function's call site too because refs were name-only. +func TestInvestigateScopesRefsByLanguage(t *testing.T) { + t.Setenv("CYMBAL_CACHE_DIR", t.TempDir()) + repo := t.TempDir() + writePhase3File(t, repo, "go.mod", "module example.com/polyglot\n\ngo 1.25\n") + writePhase3File(t, repo, "backend.go", `package backend + +type App struct { + Name string +} + +func NewApp() *App { + return &App{Name: "go-app"} +} +`) + writePhase3File(t, repo, "frontend.tsx", `import React from 'react' + +export function App() { + return null +} + +function helper() { + return App() +} +`) + runPhase3Git(t, repo, "init") + runPhase3Git(t, repo, "add", ".") + runPhase3Git(t, repo, "-c", "user.name=Cymbal Test", "-c", "user.email=cymbal@example.invalid", "commit", "-m", "initial") + if _, err := Index(repo, "", Options{Workers: 1, Force: true}); err != nil { + t.Fatal(err) + } + dbPath, err := RepoDBPath(repo) + if err != nil { + t.Fatal(err) + } + t.Cleanup(CloseAll) + + results, err := SymbolsByName(dbPath, "App") + if err != nil { + t.Fatal(err) + } + var goSym, tsxSym SymbolResult + for _, r := range results { + switch r.Language { + case "go": + goSym = r + case "tsx", "typescript": + tsxSym = r + } + } + if goSym.Name == "" || tsxSym.Name == "" { + t.Fatalf("expected both Go and TSX App symbols, got %+v", results) + } + + goRes, err := InvestigateResolved(dbPath, goSym) + if err != nil { + t.Fatal(err) + } + for _, ref := range goRes.Refs { + if strings.HasSuffix(ref.RelPath, ".tsx") { + t.Fatalf("Go App refs leaked TSX ref %s:%d", ref.RelPath, ref.Line) + } + } + + tsxRes, err := InvestigateResolved(dbPath, tsxSym) + if err != nil { + t.Fatal(err) + } + for _, ref := range tsxRes.Refs { + if strings.HasSuffix(ref.RelPath, ".go") { + t.Fatalf("TSX App refs leaked Go ref %s:%d", ref.RelPath, ref.Line) + } + } + if len(tsxRes.Refs) == 0 { + t.Fatal("TSX App should have at least one ref (helper calls App())") + } +} diff --git a/index/store.go b/index/store.go index 6a0d3b7..7b1c0fc 100644 --- a/index/store.go +++ b/index/store.go @@ -1018,6 +1018,66 @@ type RefResult struct { Name string `json:"name"` } +// FindReferencesScoped is FindReferences with a language filter. When +// language is non-empty, only refs from files in that language are returned. +// Used by investigate paths where the target symbol is unambiguously resolved +// and a same-language scope avoids mixing in calls to a same-named symbol in +// another language (e.g. Go struct App vs TSX function App). +func (s *Store) FindReferencesScoped(name, language string, limit int, kinds ...string) ([]RefResult, error) { + if language == "" { + return s.FindReferences(name, limit, kinds...) + } + if len(kinds) == 0 { + rows, err := s.db.Query(` + SELECT f.path, f.rel_path, r.line, r.name + FROM refs r JOIN files f ON r.file_id = f.id + WHERE r.name = ? AND r.language = ? + ORDER BY f.rel_path, r.line + LIMIT ? + `, name, language, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var results []RefResult + for rows.Next() { + var r RefResult + if err := rows.Scan(&r.File, &r.RelPath, &r.Line, &r.Name); err != nil { + return nil, err + } + results = append(results, r) + } + return results, rows.Err() + } + kindPlaceholders := strings.Repeat("?,", len(kinds)) + kindPlaceholders = kindPlaceholders[:len(kindPlaceholders)-1] + args := []interface{}{name, language} + for _, k := range kinds { + args = append(args, k) + } + args = append(args, limit) + rows, err := s.db.Query(` + SELECT f.path, f.rel_path, r.line, r.name + FROM refs r JOIN files f ON r.file_id = f.id + WHERE r.name = ? AND r.language = ? AND r.kind IN (`+kindPlaceholders+`) + ORDER BY f.rel_path, r.line + LIMIT ? + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var results []RefResult + for rows.Next() { + var r RefResult + if err := rows.Scan(&r.File, &r.RelPath, &r.Line, &r.Name); err != nil { + return nil, err + } + results = append(results, r) + } + return results, rows.Err() +} + // FindReferences finds files that reference a symbol name. // By default this surfaces any ref kind (call, use, implements); pass // explicit kinds to restrict (e.g. "call" to skip type-mentions). @@ -1589,6 +1649,14 @@ func (s *Store) FindTrace(symbolName string, depth, limit int, kinds ...string) // FindImpact performs transitive caller analysis using BFS. func (s *Store) FindImpact(symbolName string, depth, limit int) ([]ImpactResult, error) { + return s.FindImpactScoped(symbolName, "", depth, limit) +} + +// FindImpactScoped is FindImpact with a language filter. When language is +// non-empty, only refs and enclosing-symbols from files in that language are +// followed — used by investigate to keep cross-language same-name symbols +// from polluting transitive caller chains. +func (s *Store) FindImpactScoped(symbolName, language string, depth, limit int) ([]ImpactResult, error) { if depth <= 0 { depth = 2 } @@ -1603,11 +1671,21 @@ func (s *Store) FindImpact(symbolName string, depth, limit int) ([]ImpactResult, for d := 1; d <= depth && len(currentSymbols) > 0 && len(results) < limit; d++ { var nextSymbols []string for _, sym := range currentSymbols { - rows, err := s.db.Query(` - SELECT f.path, f.rel_path, r.line, r.name - FROM refs r JOIN files f ON r.file_id = f.id - WHERE r.name = ? - `, sym) + var rows *sql.Rows + var err error + if language == "" { + rows, err = s.db.Query(` + SELECT f.path, f.rel_path, r.line, r.name + FROM refs r JOIN files f ON r.file_id = f.id + WHERE r.name = ? + `, sym) + } else { + rows, err = s.db.Query(` + SELECT f.path, f.rel_path, r.line, r.name + FROM refs r JOIN files f ON r.file_id = f.id + WHERE r.name = ? AND r.language = ? + `, sym, language) + } if err != nil { continue } diff --git a/lang/lang_test.go b/lang/lang_test.go index 750dc76..de007b1 100644 --- a/lang/lang_test.go +++ b/lang/lang_test.go @@ -22,7 +22,7 @@ func TestForFileExtensions(t *testing.T) { {"index.js", "javascript"}, {"App.jsx", "javascript"}, {"index.ts", "typescript"}, - {"App.tsx", "typescript"}, + {"App.tsx", "tsx"}, {"lib.rs", "rust"}, {"app.rb", "ruby"}, {"Main.java", "java"}, @@ -122,7 +122,7 @@ func TestForFileSpecialFilenames(t *testing.T) { func TestSupported(t *testing.T) { // Languages with tree-sitter grammars - for _, name := range []string{"go", "python", "javascript", "typescript", "rust", "ruby", "java", "c", "cpp", "csharp", "dart", "swift", "kotlin", "lua", "php", "bash", "scala", "yaml", "elixir", "hcl", "protobuf"} { + for _, name := range []string{"go", "python", "javascript", "typescript", "tsx", "rust", "ruby", "java", "c", "cpp", "csharp", "dart", "swift", "kotlin", "lua", "php", "bash", "scala", "yaml", "elixir", "hcl", "protobuf"} { if !Default.Supported(name) { t.Errorf("Supported(%q) = false, want true", name) } diff --git a/lang/registry.go b/lang/registry.go index d872d88..b72daac 100644 --- a/lang/registry.go +++ b/lang/registry.go @@ -48,9 +48,17 @@ var Default = NewRegistry( }, Language{ Name: "typescript", - Extensions: []string{".ts", ".tsx", ".mts", ".cts"}, + Extensions: []string{".ts", ".mts", ".cts"}, TreeSitter: sitter.NewLanguage(tstypescript.LanguageTypescript()), }, + Language{ + // .tsx uses the TSX grammar so JSX parses natively. Without this split, + // anonymous arrow fns inside JSX props were misclassified as `method + // async` because the plain TS grammar can't see JSX boundaries. + Name: "tsx", + Extensions: []string{".tsx"}, + TreeSitter: sitter.NewLanguage(tstypescript.LanguageTSX()), + }, Language{ Name: "rust", Extensions: []string{".rs"}, diff --git a/parser/parser.go b/parser/parser.go index adc1b01..8ffec6a 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -344,7 +344,7 @@ func (e *symbolExtractor) extractImport(node *sitter.Node) (symbols.Import, bool return e.extractImportGo(nodeType, node) case "python": return e.extractImportPython(nodeType, node) - case "javascript", "typescript": + case "javascript", "typescript", "tsx": return e.extractImportJS(nodeType, node) case "rust": return e.extractImportRust(nodeType, node) @@ -495,7 +495,7 @@ func (e *symbolExtractor) extractRef(node *sitter.Node) (symbols.Ref, bool) { return ref, true } return e.extractRefGoCompositeLiteral(nodeType, node) - case "javascript", "typescript": + case "javascript", "typescript", "tsx": if ref, ok := e.extractRefCallExpr(nodeType, node); ok { return ref, true } @@ -834,6 +834,18 @@ func (e *symbolExtractor) nodeToSymbol(node *sitter.Node, parent string, depth i startCol = int(nameNode.StartPosition().Column) } + // tree-sitter-swift folds leading attributes (`@MainActor`, `@objc`) and + // modifiers (`public`, `final`) into the declaration node, pushing + // node.StartPosition() above the `protocol`/`class`/`struct`/`func` + // keyword. Anchor the start line to the keyword so the symbol range + // matches what users see and what `cymbal show` extracts. + if e.lang == "swift" { + if anchor := swiftKeywordAnchor(node, kind); anchor != nil { + startLine = int(anchor.StartPosition().Row) + 1 + startCol = int(anchor.StartPosition().Column) + } + } + return symbols.Symbol{ Name: name, Kind: kind, @@ -855,7 +867,7 @@ func (e *symbolExtractor) classifyNode(nodeType string, node *sitter.Node) (stri return e.classifyGo(nodeType, node) case "python": return e.classifyPython(nodeType, node) - case "javascript", "typescript": + case "javascript", "typescript", "tsx": return e.classifyJS(nodeType, node) case "rust": return e.classifyRust(nodeType, node) @@ -1578,11 +1590,13 @@ func (e *symbolExtractor) extractImportSwift(nodeType string, node *sitter.Node) return symbols.Import{RawPath: strings.TrimSpace(node.Utf8Text(e.src)), Language: e.lang}, true } -// extractRefSwift emits refs for call expressions and named type uses. -// tree-sitter-swift exposes named types as `user_type` in annotations, -// inheritance specifiers, generics, parameter types, and return types — -// trigger once per `user_type` and each nested occurrence is visited -// independently by the walker. +// extractRefSwift emits refs for call expressions, named type uses, and +// member-access (field/property) chains. tree-sitter-swift exposes named +// types as `user_type` in annotations, inheritance specifiers, generics, +// parameter types, and return types — each nested occurrence is visited +// independently by the walker. Field/property accesses appear as +// `navigation_expression` nodes; one ref per nav-expr keeps coverage +// without exploding the ref count. func (e *symbolExtractor) extractRefSwift(nodeType string, node *sitter.Node) (symbols.Ref, bool) { line := int(node.StartPosition().Row) + 1 switch nodeType { @@ -1593,6 +1607,17 @@ func (e *symbolExtractor) extractRefSwift(nodeType string, node *sitter.Node) (s if name := swiftCalleeName(node.Child(uint(0)), e.src); name != "" { return symbols.Ref{Name: name, Line: line, Language: e.lang, Kind: symbols.RefKindCall}, true } + case "navigation_expression": + // Member access. When the parent is a call_expression, the call's + // callee handler already emits the trailing identifier (the method + // name). In that case fall back to capturing the receiver — i.e. the + // thing the method is called on, like `trackingService` in + // `trackingService.track(...)`. Otherwise (non-call: assignment, + // argument, nested navigation) capture the trailing member, so + // `self.sessionID = id` records a ref for `sessionID`. + if name := swiftNavigationRef(node, e.src); name != "" { + return symbols.Ref{Name: name, Line: line, Language: e.lang, Kind: symbols.RefKindUse}, true + } case "user_type": // Type mentions (annotations, generics, return types) — not calls. // These are intentionally Kind=use so `trace` doesn't surface them @@ -1604,6 +1629,76 @@ func (e *symbolExtractor) extractRefSwift(nodeType string, node *sitter.Node) (s return symbols.Ref{}, false } +// swiftKeywordAnchor returns the leading keyword node for a Swift +// declaration so the symbol range starts at the keyword rather than at any +// preceding attributes/modifiers (e.g. `@MainActor` on a protocol). +// Returns nil when the input isn't a kind that takes attributes or no +// matching keyword child is found. +func swiftKeywordAnchor(node *sitter.Node, kind string) *sitter.Node { + var keywords []string + switch kind { + case "protocol": + keywords = []string{"protocol"} + case "class", "struct", "enum", "extension", "actor": + keywords = []string{"class", "struct", "enum", "extension", "actor", "indirect"} + case "function", "method": + keywords = []string{"func"} + case "constructor": + keywords = []string{"init"} + case "destructor": + keywords = []string{"deinit"} + case "field", "variable", "constant": + keywords = []string{"var", "let"} + default: + return nil + } + for i := range int(node.ChildCount()) { + c := node.Child(uint(i)) + ck := c.Kind() + for _, kw := range keywords { + if ck == kw { + return c + } + } + } + return nil +} + +// swiftNavigationRef returns the field/property name to record for a +// navigation_expression. When the parent is a call_expression the trailing +// identifier is the method (already captured by the call handler), so we +// emit the receiver instead. Otherwise we emit the trailing identifier. +func swiftNavigationRef(node *sitter.Node, src []byte) string { + parentIsCall := false + if p := node.Parent(); p != nil && p.Kind() == "call_expression" { + parentIsCall = true + } + if parentIsCall { + // Receiver identifier: first child of the navigation_expression. + if node.ChildCount() > 0 { + first := node.Child(uint(0)) + if first != nil && first.Kind() == "simple_identifier" { + return first.Utf8Text(src) + } + } + return "" + } + // Trailing identifier: last navigation_suffix's simple_identifier. + var lastSuffix *sitter.Node + for i := range int(node.ChildCount()) { + c := node.Child(uint(i)) + if c.Kind() == "navigation_suffix" { + lastSuffix = c + } + } + if lastSuffix != nil { + if id := findChildByType(lastSuffix, "simple_identifier"); id != nil { + return id.Utf8Text(src) + } + } + return "" +} + // swiftCalleeName resolves the callable name from a call_expression's first child. // Handles bare identifiers (`Foo()`) and navigation expressions (`x.y.z()` → `z`). func swiftCalleeName(node *sitter.Node, src []byte) string { @@ -2331,12 +2426,56 @@ func (e *symbolExtractor) classifyGeneric(nodeType string, node *sitter.Node) (s return "", nil } +// jsSignatureNode descends through JS/TS wrapper nodes (export_statement, +// lexical_declaration) to the actual function/arrow_function/method_definition +// node so parameters and return type can be located. Returns the input node +// unchanged when it's already a function-bearing node. +func jsSignatureNode(node *sitter.Node) *sitter.Node { + if node == nil { + return node + } + switch node.Kind() { + case "export_statement": + for i := range int(node.ChildCount()) { + child := node.Child(uint(i)) + switch child.Kind() { + case "function_declaration", "function", "arrow_function", "method_definition": + return child + case "lexical_declaration", "variable_declaration": + return jsSignatureNode(child) + } + } + case "lexical_declaration", "variable_declaration": + for i := range int(node.ChildCount()) { + child := node.Child(uint(i)) + if child.Kind() == "variable_declarator" { + if val := child.ChildByFieldName("value"); val != nil { + switch val.Kind() { + case "arrow_function", "function": + return val + } + } + } + } + } + return node +} + func (e *symbolExtractor) extractSignature(node *sitter.Node, kind string) string { switch kind { case "function", "method", "constructor", "destructor", "getter", "setter": if e.lang == "swift" { return swiftSignature(node, e.src) } + + // JS-family: classifyJS attaches the outer wrapper node (export_statement + // for `export function foo(...)`, lexical_declaration for + // `const foo = (x) => ...`). Descend to the actual function-bearing node + // so parameters / return type can be located. + if e.lang == "typescript" || e.lang == "tsx" || e.lang == "javascript" || e.lang == "jsx" { + node = jsSignatureNode(node) + } + var sig string // Parameters: try field name first, then language-specific node types. @@ -2414,7 +2553,7 @@ func (e *symbolExtractor) extractImplements(node *sitter.Node) []symbols.Ref { return e.extractImplementsKotlin(node) case "scala": return e.extractImplementsScala(node) - case "typescript", "javascript": + case "typescript", "javascript", "tsx": return e.extractImplementsTSJS(node) case "rust": return e.extractImplementsRust(node) diff --git a/parser/parser_feature_test.go b/parser/parser_feature_test.go index b232041..b875e25 100644 --- a/parser/parser_feature_test.go +++ b/parser/parser_feature_test.go @@ -1,6 +1,7 @@ package parser import ( + "strings" "testing" "github.com/1broseidon/cymbal/lang" @@ -1375,6 +1376,85 @@ func make() -> BabyTrackingService { // (BabyTrackingService is also the protocol's name — confirmed via any occurrence.) } +// Regression: field/property accesses (`self.field`, `field.method()`, +// `self.field.subfield = x`) used to return zero refs because tree-sitter +// `navigation_expression` nodes weren't visited by extractRefSwift. +func TestFeatureSwiftFieldRefs(t *testing.T) { + src := []byte(`class TrackingService { + var sessionID: String = "" + + init(sessionID: String) { + self.sessionID = sessionID + } +} + +class AppCoordinator { + let trackingService: TrackingService + + init(trackingService: TrackingService) { + self.trackingService = trackingService + } + + func start() { + self.trackingService.track(event: "start") + trackingService.track(event: "ready") + } + + func update(id: String) { + self.trackingService.sessionID = id + } +} +`) + result, err := ParseSource(src, "test.swift", "swift", lang.Default.TreeSitter("swift")) + if err != nil { + t.Fatal(err) + } + + countRefs := func(name string) int { + n := 0 + for _, r := range result.Refs { + if r.Name == name { + n++ + } + } + return n + } + if got := countRefs("trackingService"); got < 4 { + debugParseResult(t, result) + t.Fatalf("trackingService refs = %d, want >= 4 (assignment, two method calls, nested access)", got) + } + if got := countRefs("sessionID"); got < 2 { + debugParseResult(t, result) + t.Fatalf("sessionID refs = %d, want >= 2 (init assignment, nested access)", got) + } +} + +// Regression: protocols with leading attributes (`@MainActor`) had their +// symbol range start one line above the `protocol` keyword. Anchoring to +// the keyword keeps `cymbal show`/`outline` aligned with what users grep. +func TestFeatureSwiftAttributedProtocolStartsAtKeyword(t *testing.T) { + src := []byte(`import Foundation + +@MainActor +protocol Tracker { + func track(event: String) +} +`) + result, err := ParseSource(src, "test.swift", "swift", lang.Default.TreeSitter("swift")) + if err != nil { + t.Fatal(err) + } + sym := findSymbolKind(result.Symbols, "Tracker", "protocol") + if sym == nil { + debugParseResult(t, result) + t.Fatal("expected Tracker protocol") + } + // `@MainActor` is L3, `protocol Tracker` is L4. The symbol must start at L4. + if sym.StartLine != 4 { + t.Errorf("Tracker.StartLine = %d, want 4 (the `protocol` keyword line)", sym.StartLine) + } +} + // --- C Language Feature Tests --- func TestFeatureCRefs(t *testing.T) { @@ -2444,3 +2524,98 @@ run_task "world" } } } + +// Regression: anonymous arrow fns inside JSX props were misclassified as +// `method async` (the `async` keyword was harvested as the symbol name) +// because .tsx files were parsed with the plain TS grammar that can't see +// JSX. Fixed by routing .tsx through tree-sitter's TSX grammar. +func TestFeatureTSXJsxPropArrowFnsAreNotIndexed(t *testing.T) { + src := []byte(`import React from 'react' + +export function App() { + return ( +
+ + + +
+ ) +} +`) + result, err := ParseSource(src, "App.tsx", "tsx", lang.Default.TreeSitter("tsx")) + if err != nil { + t.Fatal(err) + } + + if findSymbolKind(result.Symbols, "App", "function") == nil { + debugParseResult(t, result) + t.Fatal("expected to find App function") + } + for _, sym := range result.Symbols { + if sym.Name == "async" { + debugParseResult(t, result) + t.Fatalf("JSX-prop arrow fn should not produce a symbol named 'async': %+v", sym) + } + if sym.Kind == "method" && sym.Parent == "App" { + debugParseResult(t, result) + t.Fatalf("anonymous JSX-prop fn must not be indexed as a method on App: %+v", sym) + } + } +} + +// Regression: signatures were missing on `export function ...` declarations +// because classifyJS attaches the outer export_statement node to the symbol +// and extractSignature looked for parameters on that wrapper. Same for +// `export const fn = (x) => ...` (lexical_declaration wrapper). +func TestFeatureTSExportFunctionsRetainSignature(t *testing.T) { + src := []byte(`export function fetchUser(id: string): Promise<{ id: string }> { + return Promise.resolve({ id }) +} + +export async function saveUser( + id: string, + name: string, +): Promise { + return +} + +function helperLocal(x: number): number { + return x + 1 +} + +export const inlineConst = (id: string): string => id +`) + result, err := ParseSource(src, "api.ts", "typescript", lang.Default.TreeSitter("typescript")) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + wantInclude string + }{ + {"fetchUser", "(id: string)"}, + {"saveUser", "id: string"}, + {"helperLocal", "(x: number): number"}, + {"inlineConst", "(id: string)"}, + } + for _, tc := range cases { + sym := findSymbol(result.Symbols, tc.name) + if sym == nil { + debugParseResult(t, result) + t.Fatalf("expected to find %s", tc.name) + } + if sym.Signature == "" { + t.Errorf("%s: signature is empty (expected to contain %q)", tc.name, tc.wantInclude) + continue + } + if !strings.Contains(sym.Signature, tc.wantInclude) { + t.Errorf("%s: signature %q missing %q", tc.name, sym.Signature, tc.wantInclude) + } + } +} diff --git a/walker/walker_feature_test.go b/walker/walker_feature_test.go index 97f7ade..1d208a4 100644 --- a/walker/walker_feature_test.go +++ b/walker/walker_feature_test.go @@ -183,7 +183,7 @@ func TestFeatureWalkerLanguageDetection(t *testing.T) { {"test.js", "javascript"}, {"test.jsx", "javascript"}, {"test.ts", "typescript"}, - {"test.tsx", "typescript"}, + {"test.tsx", "tsx"}, {"test.rs", "rust"}, {"test.rb", "ruby"}, {"test.java", "java"},