Skip to content

Commit e2b309f

Browse files
authored
Merge pull request #1594 from optave/fix/titan-parity-swift-jelly-micro
fix(parity): align WASM/native extraction for swift and jelly-micro fixtures
2 parents 97261c3 + 0301a61 commit e2b309f

5 files changed

Lines changed: 178 additions & 6 deletions

File tree

crates/codegraph-core/src/extractors/swift.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,22 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept
299299
}
300300
"navigation_expression" => {
301301
let last = fn_node.child(fn_node.child_count().saturating_sub(1));
302-
let name = last
303-
.map(|n| node_text(&n, source).to_string())
304-
.unwrap_or_else(|| node_text(&fn_node, source).to_string());
302+
// Swift's grammar wraps the method name in a `navigation_suffix` node
303+
// (e.g. `.save` text), not a bare `simple_identifier`. Descend into
304+
// navigation_suffix to get the inner simple_identifier so the resolved
305+
// name is "save" not ".save". Mirrors WASM extractors/swift.ts fix.
306+
let name = last.map(|n| {
307+
if n.kind() == "navigation_suffix" {
308+
find_child(&n, "simple_identifier")
309+
.map(|id| node_text(&id, source).to_string())
310+
.unwrap_or_else(|| {
311+
let t = node_text(&n, source);
312+
t.trim_start_matches('.').to_string()
313+
})
314+
} else {
315+
node_text(&n, source).to_string()
316+
}
317+
}).unwrap_or_else(|| node_text(&fn_node, source).to_string());
305318
let receiver = fn_node.child(0)
306319
.map(|n| node_text(&n, source).to_string());
307320
symbols.calls.push(Call {
@@ -376,4 +389,25 @@ mod tests {
376389
assert_eq!(s.imports[0].source, "Foundation");
377390
assert!(s.imports[0].swift_import.unwrap());
378391
}
392+
393+
/// navigation_expression uses a `navigation_suffix` child — the method name
394+
/// must be extracted as a bare identifier ("save"), not ".save".
395+
#[test]
396+
fn extracts_navigation_call_bare_name() {
397+
let s = parse_swift("func f() { repo.save(x) }");
398+
let call = s.calls.iter().find(|c| c.receiver.as_deref() == Some("repo")).unwrap();
399+
assert_eq!(call.name, "save", "method name must not include leading dot");
400+
assert_eq!(call.receiver.as_deref(), Some("repo"));
401+
}
402+
403+
/// Class property with a type annotation seeds the typeMap so that
404+
/// receiver-typed call edges can be resolved.
405+
#[test]
406+
fn class_property_type_annotation_seeds_type_map() {
407+
let s = parse_swift("class Service { private let repo: Repository }");
408+
let entry = s.type_map.iter().find(|e| e.name == "repo");
409+
assert!(entry.is_some(), "repo should appear in type_map");
410+
assert_eq!(entry.unwrap().type_name, "Repository");
411+
assert_eq!(entry.unwrap().confidence, 0.9);
412+
}
379413
}

src/extractors/javascript.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ function extractConstantsWalk(node: TreeSitterNode, definitions: Definition[]):
481481
}
482482

483483
extractConstDeclarators(declNode, definitions);
484+
extractLetVarObjLiteralDeclarators(declNode, definitions);
484485

485486
// Recurse into non-function, non-export-statement children (blocks, if-statements, etc.)
486487
if (child.type !== 'export_statement') {
@@ -585,6 +586,34 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti
585586
}
586587
}
587588

589+
/**
590+
* Extract qualified method definitions from `let`/`var` object-literal declarations.
591+
* Mirrors `match_js_objlit_qualified_method_defs` in `javascript.rs`, which emits
592+
* qualified definitions for `method_definition` (all declaration kinds) and
593+
* `pair+arrow/function` (`let`/`var` only, since `const` is already handled by
594+
* `extractConstDeclarators` → `extractObjectLiteralFunctions`).
595+
*
596+
* Called from extractConstantsWalk which already provides the function-scope guard.
597+
* `var q1 = { m1() {} }` → emits Definition { name: 'q1.m1', kind: 'function' }
598+
*/
599+
function extractLetVarObjLiteralDeclarators(
600+
declNode: TreeSitterNode,
601+
definitions: Definition[],
602+
): void {
603+
const t = declNode.type;
604+
if (t !== 'lexical_declaration' && t !== 'variable_declaration') return;
605+
if (declNode.text.startsWith('const ')) return; // handled by extractConstDeclarators
606+
607+
for (let j = 0; j < declNode.childCount; j++) {
608+
const declarator = declNode.child(j);
609+
if (declarator?.type !== 'variable_declarator') continue;
610+
const nameN = declarator.childForFieldName('name');
611+
const valueN = declarator.childForFieldName('value');
612+
if (nameN?.type !== 'identifier' || !valueN || valueN.type !== 'object') continue;
613+
extractObjectLiteralFunctions(valueN, nameN.text, definitions);
614+
}
615+
}
616+
588617
/**
589618
* Recursive walk to find dynamic import() calls.
590619
* Query patterns match call_expression with identifier/member_expression/subscript_expression
@@ -1043,6 +1072,18 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
10431072
if (valueN.type === 'object') {
10441073
extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions);
10451074
}
1075+
} else if (
1076+
!isConst &&
1077+
nameN.type === 'identifier' &&
1078+
valueN.type === 'object' &&
1079+
!hasFunctionScopeAncestor(node)
1080+
) {
1081+
// `let`/`var` object literals: extract qualified method definitions so that
1082+
// `obj.method()` calls resolve correctly. Mirrors Rust match_js_objlit_qualified_method_defs
1083+
// which emits method_definition qualified names for ALL declaration kinds and
1084+
// pair+arrow/function for let/var only (const is already handled above).
1085+
// Scope guard prevents local object properties from polluting the global index.
1086+
extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions);
10461087
} else if (isConst && nameN.type === 'object_pattern' && !hasFunctionScopeAncestor(node)) {
10471088
// Destructured bindings: const { handleToken, checkPermissions } = initAuth(...)
10481089
// Each destructured property becomes a function definition so it can be

src/extractors/swift.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function walkSwiftNode(node: TreeSitterNode, ctx: ExtractorOutput): void {
4242
handleSwiftCallExpression(node, ctx);
4343
break;
4444
case 'property_declaration':
45+
seedSwiftPropertyTypeMap(node, ctx);
4546
handleSwiftPropertyDecl(node, ctx);
4647
break;
4748
}
@@ -250,11 +251,26 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput):
250251
if (!funcNode) return;
251252
const call: Call = { name: '', line: node.startPosition.row + 1 };
252253
if (funcNode.type === 'navigation_expression') {
253-
// obj.method(...)
254+
// obj.method(...) — Swift's tree-sitter grammar wraps the suffix in a
255+
// `navigation_suffix` node: navigation_expression > [simple_identifier, navigation_suffix].
256+
// We must descend into navigation_suffix to get the bare method name (e.g. "save" not ".save").
257+
// Mirrors Rust match_swift_node which also descends into navigation_suffix via find_child
258+
// to extract the inner simple_identifier, with a trim_start_matches('.') fallback.
254259
const lastChild = funcNode.child(funcNode.childCount - 1);
255260
const firstChild = funcNode.child(0);
256-
if (lastChild && lastChild.type === 'simple_identifier' && firstChild) {
257-
call.name = lastChild.text;
261+
if (lastChild && firstChild) {
262+
// Resolve the method name: descend into navigation_suffix to find the
263+
// simple_identifier, or fall back to stripping the leading dot from the text.
264+
let methodName: string;
265+
if (lastChild.type === 'simple_identifier') {
266+
methodName = lastChild.text;
267+
} else if (lastChild.type === 'navigation_suffix') {
268+
const inner = findChild(lastChild, 'simple_identifier');
269+
methodName = inner ? inner.text : lastChild.text.replace(/^\./, '');
270+
} else {
271+
methodName = lastChild.text;
272+
}
273+
call.name = methodName;
258274
call.receiver = firstChild.text;
259275
}
260276
} else if (funcNode.type === 'simple_identifier') {
@@ -265,6 +281,33 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput):
265281
if (call.name) ctx.calls.push(call);
266282
}
267283

284+
/**
285+
* Seed the typeMap for a property_declaration with a type annotation.
286+
* This runs for ALL property_declaration nodes (including class-body ones)
287+
* so that `repo.method()` calls can be resolved to the correct class.
288+
* Mirrors Rust match_swift_type_map which walks all nodes unconditionally.
289+
*/
290+
function seedSwiftPropertyTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void {
291+
const typeAnn = findChild(node, 'type_annotation');
292+
if (!typeAnn) return;
293+
// type_annotation: ":" <user_type | simple_identifier | ...>
294+
// The last child is the actual type node.
295+
const lastChild = typeAnn.child(typeAnn.childCount - 1);
296+
if (!lastChild) return;
297+
// For "user_type > type_identifier", grab the inner identifier text;
298+
// for a plain simple_identifier, use it directly.
299+
const typeNode =
300+
lastChild.type === 'user_type' ? findChild(lastChild, 'type_identifier') : lastChild;
301+
if (!typeNode) return;
302+
const typeName = typeNode.text;
303+
if (!typeName) return;
304+
const pattern = findChild(node, 'pattern');
305+
if (!pattern) return;
306+
const varName = findChild(pattern, 'simple_identifier')?.text ?? pattern.text;
307+
if (!varName) return;
308+
ctx.typeMap.set(varName, { type: typeName, confidence: 0.9 });
309+
}
310+
268311
function handleSwiftPropertyDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
269312
// Only handle top-level properties (class properties are handled inline)
270313
if (

tests/parsers/javascript.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,37 @@ describe('JavaScript parser', () => {
891891
);
892892
});
893893

894+
// let/var object-literal method definitions
895+
it('extracts qualified definitions from var object-literal arrow functions', () => {
896+
// `var x = { a: function() {} }` — native produces `x.a`, WASM must too.
897+
// Parity fix: extractLetVarObjLiteralDeclarators covers let/var (const already
898+
// handled by extractConstDeclarators → extractObjectLiteralFunctions).
899+
const symbols = parseJS(`var x = { a: function() {}, b: () => {} };`);
900+
expect(symbols.definitions).toContainEqual(
901+
expect.objectContaining({ name: 'x.a', kind: 'function' }),
902+
);
903+
expect(symbols.definitions).toContainEqual(
904+
expect.objectContaining({ name: 'x.b', kind: 'function' }),
905+
);
906+
});
907+
908+
it('extracts qualified definitions from let object-literal shorthand methods', () => {
909+
// `let x12 = { f13() {} }` — matches jelly-micro classes.js fixtures.
910+
const symbols = parseJS(`let x12 = { f13() {}, f14: () => {} };`);
911+
expect(symbols.definitions).toContainEqual(
912+
expect.objectContaining({ name: 'x12.f13', kind: 'function' }),
913+
);
914+
expect(symbols.definitions).toContainEqual(
915+
expect.objectContaining({ name: 'x12.f14', kind: 'function' }),
916+
);
917+
});
918+
919+
it('does not extract let/var object-literal definitions inside function scope', () => {
920+
// Scope guard mirrors const path — skips object literals inside function bodies.
921+
const symbols = parseJS(`function setup() { var local = { f() {} }; }`);
922+
expect(symbols.definitions).not.toContainEqual(expect.objectContaining({ name: 'local.f' }));
923+
});
924+
894925
// Line range verification
895926
it('sets correct line and endLine on callback definition', () => {
896927
const code = [

tests/parsers/swift.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,27 @@ describe('Swift parser', () => {
8585
expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'print' }));
8686
expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'bar' }));
8787
});
88+
89+
it('extracts navigation_expression calls with bare method name and receiver', () => {
90+
// navigation_expression uses a navigation_suffix child node — method name
91+
// must be "save" not ".save" so the call resolver can find UserRepository.save.
92+
const symbols = parseSwift(`func f() { repo.save(x) }`);
93+
const call = symbols.calls.find((c) => c.receiver === 'repo');
94+
expect(call).toBeDefined();
95+
expect(call!.name).toBe('save');
96+
expect(call!.receiver).toBe('repo');
97+
});
98+
99+
it('seeds typeMap from class property type annotations', () => {
100+
// `private let repo: UserRepository` in a class body must seed typeMap
101+
// so that receiver-typed call edges (repo.save → UserRepository) can resolve.
102+
const symbols = parseSwift(`class Service {
103+
private let repo: UserRepository
104+
func createUser() { repo.save(x) }
105+
}`);
106+
const entry = symbols.typeMap.get('repo');
107+
expect(entry).toBeDefined();
108+
expect(entry!.type).toBe('UserRepository');
109+
expect(entry!.confidence).toBe(0.9);
110+
});
88111
});

0 commit comments

Comments
 (0)