diff --git a/crates/codegraph-core/src/extractors/dart.rs b/crates/codegraph-core/src/extractors/dart.rs index de27e1a0a..af07472a1 100644 --- a/crates/codegraph-core/src/extractors/dart.rs +++ b/crates/codegraph-core/src/extractors/dart.rs @@ -1113,8 +1113,18 @@ fn handle_dart_selector(node: &Node, source: &[u8], symbols: &mut FileSymbols) { /// is `None` for a bare call (Layout C) or when the preceding token isn't a /// plain identifier/type_identifier (a chained call's intermediate receiver /// or a subscript-indexed receiver). +/// A `.method` access (`unconditional_assignable_selector`) and a null-aware +/// `?.method` access (`conditional_assignable_selector`) are otherwise +/// identical for call-resolution purposes — both wrap a plain `identifier` +/// naming the method — so every lookup below tries either wrapper (#2476). +/// Mirrors `findDartAssignableSelector` in `src/extractors/dart.ts`. +fn find_dart_assignable_selector<'a>(node: &Node<'a>) -> Option> { + find_child(node, "unconditional_assignable_selector") + .or_else(|| find_child(node, "conditional_assignable_selector")) +} + fn resolve_dart_selector_call(node: &Node, source: &[u8]) -> Option<(String, Option)> { - if let Some(unconditional) = find_child(node, "unconditional_assignable_selector") { + if let Some(unconditional) = find_dart_assignable_selector(node) { let id = find_child(&unconditional, "identifier")?; let receiver = find_dart_selector_receiver(node, source); return Some((node_text(&id, source).to_string(), receiver)); @@ -1133,7 +1143,7 @@ fn resolve_dart_selector_call(node: &Node, source: &[u8]) -> Option<(String, Opt let prev_sibling = prev_sibling?; if prev_sibling.kind() == "selector" { - let unc2 = find_child(&prev_sibling, "unconditional_assignable_selector")?; + let unc2 = find_dart_assignable_selector(&prev_sibling)?; let id2 = find_child(&unc2, "identifier")?; let receiver = find_dart_selector_receiver(&prev_sibling, source); return Some((node_text(&id2, source).to_string(), receiver)); @@ -1232,7 +1242,11 @@ fn handle_dart_call_expression(node: &Node, source: &[u8], symbols: &mut FileSym let name = node_text(&func, source).to_string(); push_simple_call(symbols, node, name); } - "member_expression" => { + // `null_aware_member_expression` (`a?.b()`) is otherwise identical to + // `member_expression` (`a.b()`) — same `object`/`property` fields — + // just a distinct node kind for the null-aware `?.` accessor + // (confirmed by parsing `a?.b();` with tree-sitter-dart 0.2; #2476). + "member_expression" | "null_aware_member_expression" => { let Some(property) = func.child_by_field_name("property") else { return; }; @@ -1388,6 +1402,48 @@ mod tests { } } + // #2476: `a?.b()` parses its callee as a `null_aware_member_expression` + // node — otherwise identical to `member_expression` (same object/property + // fields), but a distinct node kind `handle_dart_call_expression` never + // matched, so a null-aware method call was silently extracted as ZERO + // calls (not misresolved — dropped entirely). + mod null_aware_calls { + use super::*; + + #[test] + fn extracts_a_null_aware_method_call() { + let s = parse_dart("void f() {\n a?.b();\n}"); + assert!( + s.calls.iter().any(|c| c.name == "b"), + "expected a call named 'b'; got: {:?}", + s.calls + ); + } + + #[test] + fn sets_a_this_prefixed_receiver_for_a_bare_identifier_object() { + let s = parse_dart("void f() {\n a?.b();\n}"); + let call = s.calls.iter().find(|c| c.name == "b"); + assert_eq!(call.and_then(|c| c.receiver.as_deref()), Some("this.a")); + } + + #[test] + fn resolves_each_call_in_a_null_aware_chained_sequence() { + let s = parse_dart("void main() {\n obj?.method1()?.method2();\n}"); + let names: Vec<&str> = s.calls.iter().map(|c| c.name.as_str()).collect(); + assert!( + names.contains(&"method1"), + "missing method1; got: {:?}", + names + ); + assert!( + names.contains(&"method2"), + "missing method2; got: {:?}", + names + ); + } + } + // #2082: function_signature/method_signature and function_body are // SIBLING nodes in tree-sitter-dart, not parent-child, so end_line must // be measured through to the sibling body. diff --git a/src/extractors/dart.ts b/src/extractors/dart.ts index 936543536..4da40c02e 100644 --- a/src/extractors/dart.ts +++ b/src/extractors/dart.ts @@ -901,6 +901,17 @@ interface DartSelectorCall { receiver?: string; } +// A `.method` access (`unconditional_assignable_selector`) and a null-aware +// `?.method` access (`conditional_assignable_selector`) are otherwise +// identical for call-resolution purposes — both wrap a plain `identifier` +// naming the method — so every lookup below tries either wrapper (#2476). +function findDartAssignableSelector(node: TreeSitterNode): TreeSitterNode | null { + return ( + findChild(node, 'unconditional_assignable_selector') || + findChild(node, 'conditional_assignable_selector') + ); +} + // Look for the identifier this selector belongs to, plus (for a genuine // `.method` access) its receiver, for typeMap-based call resolution (#2319). // Three layouts are possible depending on grammar version and call shape: @@ -916,8 +927,12 @@ interface DartSelectorCall { // wrapping call_expression — #2082). This is a bare call, not a // receiver+method pair — the identifier IS the callee's own name, so // no receiver is produced. +// A/B both apply identically to a null-aware `?.method` access — confirmed +// by parsing `a?.b();`, which produces the exact same Layout B shape as +// `a.b();` with `conditional_assignable_selector` in place of +// `unconditional_assignable_selector` (#2476). function resolveDartSelectorCall(node: TreeSitterNode): DartSelectorCall | null { - const unconditional = findChild(node, 'unconditional_assignable_selector'); + const unconditional = findDartAssignableSelector(node); if (unconditional) { const id = findChild(unconditional, 'identifier'); if (!id) return null; @@ -947,7 +962,7 @@ function resolveDartSelectorCall(node: TreeSitterNode): DartSelectorCall | null if (!prevSibling) return null; if (prevSibling.type === 'selector') { - const unc2 = findChild(prevSibling, 'unconditional_assignable_selector'); + const unc2 = findDartAssignableSelector(prevSibling); const id2 = unc2 ? findChild(unc2, 'identifier') : null; if (!id2) return null; const receiver = findDartSelectorReceiver(prevSibling); diff --git a/tests/parsers/dart.test.ts b/tests/parsers/dart.test.ts index 271856502..5336e679a 100644 --- a/tests/parsers/dart.test.ts +++ b/tests/parsers/dart.test.ts @@ -113,6 +113,37 @@ import 'package:flutter/material.dart';`); }); }); + // #2476: `a?.b()` uses a distinct `conditional_assignable_selector` node + // (`?.`) instead of the `unconditional_assignable_selector` (`.`) ordinary + // member access uses — resolveDartSelectorCall's Layout A/B checks only + // looked for the unconditional wrapper, so a null-aware method call was + // silently extracted as ZERO calls (not misresolved — dropped entirely). + describe('#2476: null-aware (conditional) method call extraction', () => { + it('extracts a null-aware method call', () => { + const symbols = parseDart(`void f() { + a?.b(); +}`); + expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'b' })); + }); + + it('sets a this-prefixed receiver for a bare identifier object', () => { + const symbols = parseDart(`void f() { + a?.b(); +}`); + const call = symbols.calls.find((c) => c.name === 'b'); + expect(call?.receiver).toBe('this.a'); + }); + + it('resolves each call in a null-aware chained sequence', () => { + const symbols = parseDart(`void main() { + obj?.method1()?.method2(); +}`); + const names = symbols.calls.map((c) => c.name); + expect(names).toContain('method1'); + expect(names).toContain('method2'); + }); + }); + // #2082: multi-line function/method endLine truncation — tree-sitter-dart // splits a function's signature and body into SIBLING nodes // (function_signature/method_signature + function_body), not a