diff --git a/crates/codegraph-core/src/extractors/swift.rs b/crates/codegraph-core/src/extractors/swift.rs index 12358f53..898bfb42 100644 --- a/crates/codegraph-core/src/extractors/swift.rs +++ b/crates/codegraph-core/src/extractors/swift.rs @@ -299,9 +299,22 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept } "navigation_expression" => { let last = fn_node.child(fn_node.child_count().saturating_sub(1)); - let name = last - .map(|n| node_text(&n, source).to_string()) - .unwrap_or_else(|| node_text(&fn_node, source).to_string()); + // Swift's grammar wraps the method name in a `navigation_suffix` node + // (e.g. `.save` text), not a bare `simple_identifier`. Descend into + // navigation_suffix to get the inner simple_identifier so the resolved + // name is "save" not ".save". Mirrors WASM extractors/swift.ts fix. + let name = last.map(|n| { + if n.kind() == "navigation_suffix" { + find_child(&n, "simple_identifier") + .map(|id| node_text(&id, source).to_string()) + .unwrap_or_else(|| { + let t = node_text(&n, source); + t.trim_start_matches('.').to_string() + }) + } else { + node_text(&n, source).to_string() + } + }).unwrap_or_else(|| node_text(&fn_node, source).to_string()); let receiver = fn_node.child(0) .map(|n| node_text(&n, source).to_string()); symbols.calls.push(Call { @@ -376,4 +389,25 @@ mod tests { assert_eq!(s.imports[0].source, "Foundation"); assert!(s.imports[0].swift_import.unwrap()); } + + /// navigation_expression uses a `navigation_suffix` child — the method name + /// must be extracted as a bare identifier ("save"), not ".save". + #[test] + fn extracts_navigation_call_bare_name() { + let s = parse_swift("func f() { repo.save(x) }"); + let call = s.calls.iter().find(|c| c.receiver.as_deref() == Some("repo")).unwrap(); + assert_eq!(call.name, "save", "method name must not include leading dot"); + assert_eq!(call.receiver.as_deref(), Some("repo")); + } + + /// Class property with a type annotation seeds the typeMap so that + /// receiver-typed call edges can be resolved. + #[test] + fn class_property_type_annotation_seeds_type_map() { + let s = parse_swift("class Service { private let repo: Repository }"); + let entry = s.type_map.iter().find(|e| e.name == "repo"); + assert!(entry.is_some(), "repo should appear in type_map"); + assert_eq!(entry.unwrap().type_name, "Repository"); + assert_eq!(entry.unwrap().confidence, 0.9); + } } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 4fef18da..29abdba7 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -474,6 +474,7 @@ function extractConstantsWalk(node: TreeSitterNode, definitions: Definition[]): } extractConstDeclarators(declNode, definitions); + extractLetVarObjLiteralDeclarators(declNode, definitions); // Recurse into non-function, non-export-statement children (blocks, if-statements, etc.) if (child.type !== 'export_statement') { @@ -578,6 +579,34 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti } } +/** + * Extract qualified method definitions from `let`/`var` object-literal declarations. + * Mirrors `match_js_objlit_qualified_method_defs` in `javascript.rs`, which emits + * qualified definitions for `method_definition` (all declaration kinds) and + * `pair+arrow/function` (`let`/`var` only, since `const` is already handled by + * `extractConstDeclarators` → `extractObjectLiteralFunctions`). + * + * Called from extractConstantsWalk which already provides the function-scope guard. + * `var q1 = { m1() {} }` → emits Definition { name: 'q1.m1', kind: 'function' } + */ +function extractLetVarObjLiteralDeclarators( + declNode: TreeSitterNode, + definitions: Definition[], +): void { + const t = declNode.type; + if (t !== 'lexical_declaration' && t !== 'variable_declaration') return; + if (declNode.text.startsWith('const ')) return; // handled by extractConstDeclarators + + for (let j = 0; j < declNode.childCount; j++) { + const declarator = declNode.child(j); + if (declarator?.type !== 'variable_declarator') continue; + const nameN = declarator.childForFieldName('name'); + const valueN = declarator.childForFieldName('value'); + if (nameN?.type !== 'identifier' || !valueN || valueN.type !== 'object') continue; + extractObjectLiteralFunctions(valueN, nameN.text, definitions); + } +} + /** * Recursive walk to find dynamic import() calls. * Query patterns match call_expression with identifier/member_expression/subscript_expression @@ -1036,6 +1065,18 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { if (valueN.type === 'object') { extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions); } + } else if ( + !isConst && + nameN.type === 'identifier' && + valueN.type === 'object' && + !hasFunctionScopeAncestor(node) + ) { + // `let`/`var` object literals: extract qualified method definitions so that + // `obj.method()` calls resolve correctly. Mirrors Rust match_js_objlit_qualified_method_defs + // which emits method_definition qualified names for ALL declaration kinds and + // pair+arrow/function for let/var only (const is already handled above). + // Scope guard prevents local object properties from polluting the global index. + extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions); } else if (isConst && nameN.type === 'object_pattern' && !hasFunctionScopeAncestor(node)) { // Destructured bindings: const { handleToken, checkPermissions } = initAuth(...) // Each destructured property becomes a function definition so it can be diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index a31acc5b..9b2f6e90 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -42,6 +42,7 @@ function walkSwiftNode(node: TreeSitterNode, ctx: ExtractorOutput): void { handleSwiftCallExpression(node, ctx); break; case 'property_declaration': + seedSwiftPropertyTypeMap(node, ctx); handleSwiftPropertyDecl(node, ctx); break; } @@ -250,11 +251,26 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): if (!funcNode) return; const call: Call = { name: '', line: node.startPosition.row + 1 }; if (funcNode.type === 'navigation_expression') { - // obj.method(...) + // obj.method(...) — Swift's tree-sitter grammar wraps the suffix in a + // `navigation_suffix` node: navigation_expression > [simple_identifier, navigation_suffix]. + // We must descend into navigation_suffix to get the bare method name (e.g. "save" not ".save"). + // Mirrors Rust match_swift_node which also descends into navigation_suffix via find_child + // to extract the inner simple_identifier, with a trim_start_matches('.') fallback. const lastChild = funcNode.child(funcNode.childCount - 1); const firstChild = funcNode.child(0); - if (lastChild && lastChild.type === 'simple_identifier' && firstChild) { - call.name = lastChild.text; + if (lastChild && firstChild) { + // Resolve the method name: descend into navigation_suffix to find the + // simple_identifier, or fall back to stripping the leading dot from the text. + let methodName: string; + if (lastChild.type === 'simple_identifier') { + methodName = lastChild.text; + } else if (lastChild.type === 'navigation_suffix') { + const inner = findChild(lastChild, 'simple_identifier'); + methodName = inner ? inner.text : lastChild.text.replace(/^\./, ''); + } else { + methodName = lastChild.text; + } + call.name = methodName; call.receiver = firstChild.text; } } else if (funcNode.type === 'simple_identifier') { @@ -265,6 +281,33 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): if (call.name) ctx.calls.push(call); } +/** + * Seed the typeMap for a property_declaration with a type annotation. + * This runs for ALL property_declaration nodes (including class-body ones) + * so that `repo.method()` calls can be resolved to the correct class. + * Mirrors Rust match_swift_type_map which walks all nodes unconditionally. + */ +function seedSwiftPropertyTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void { + const typeAnn = findChild(node, 'type_annotation'); + if (!typeAnn) return; + // type_annotation: ":" + // The last child is the actual type node. + const lastChild = typeAnn.child(typeAnn.childCount - 1); + if (!lastChild) return; + // For "user_type > type_identifier", grab the inner identifier text; + // for a plain simple_identifier, use it directly. + const typeNode = + lastChild.type === 'user_type' ? findChild(lastChild, 'type_identifier') : lastChild; + if (!typeNode) return; + const typeName = typeNode.text; + if (!typeName) return; + const pattern = findChild(node, 'pattern'); + if (!pattern) return; + const varName = findChild(pattern, 'simple_identifier')?.text ?? pattern.text; + if (!varName) return; + ctx.typeMap.set(varName, { type: typeName, confidence: 0.9 }); +} + function handleSwiftPropertyDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { // Only handle top-level properties (class properties are handled inline) if ( diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 5ba523fa..5d53ecee 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -891,6 +891,37 @@ describe('JavaScript parser', () => { ); }); + // let/var object-literal method definitions + it('extracts qualified definitions from var object-literal arrow functions', () => { + // `var x = { a: function() {} }` — native produces `x.a`, WASM must too. + // Parity fix: extractLetVarObjLiteralDeclarators covers let/var (const already + // handled by extractConstDeclarators → extractObjectLiteralFunctions). + const symbols = parseJS(`var x = { a: function() {}, b: () => {} };`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x.a', kind: 'function' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x.b', kind: 'function' }), + ); + }); + + it('extracts qualified definitions from let object-literal shorthand methods', () => { + // `let x12 = { f13() {} }` — matches jelly-micro classes.js fixtures. + const symbols = parseJS(`let x12 = { f13() {}, f14: () => {} };`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x12.f13', kind: 'function' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x12.f14', kind: 'function' }), + ); + }); + + it('does not extract let/var object-literal definitions inside function scope', () => { + // Scope guard mirrors const path — skips object literals inside function bodies. + const symbols = parseJS(`function setup() { var local = { f() {} }; }`); + expect(symbols.definitions).not.toContainEqual(expect.objectContaining({ name: 'local.f' })); + }); + // Line range verification it('sets correct line and endLine on callback definition', () => { const code = [ diff --git a/tests/parsers/swift.test.ts b/tests/parsers/swift.test.ts index 5541bf20..d08eed6e 100644 --- a/tests/parsers/swift.test.ts +++ b/tests/parsers/swift.test.ts @@ -85,4 +85,27 @@ describe('Swift parser', () => { expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'print' })); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'bar' })); }); + + it('extracts navigation_expression calls with bare method name and receiver', () => { + // navigation_expression uses a navigation_suffix child node — method name + // must be "save" not ".save" so the call resolver can find UserRepository.save. + const symbols = parseSwift(`func f() { repo.save(x) }`); + const call = symbols.calls.find((c) => c.receiver === 'repo'); + expect(call).toBeDefined(); + expect(call!.name).toBe('save'); + expect(call!.receiver).toBe('repo'); + }); + + it('seeds typeMap from class property type annotations', () => { + // `private let repo: UserRepository` in a class body must seed typeMap + // so that receiver-typed call edges (repo.save → UserRepository) can resolve. + const symbols = parseSwift(`class Service { + private let repo: UserRepository + func createUser() { repo.save(x) } +}`); + const entry = symbols.typeMap.get('repo'); + expect(entry).toBeDefined(); + expect(entry!.type).toBe('UserRepository'); + expect(entry!.confidence).toBe(0.9); + }); });