From 3f96f80fcb702a96c893ffefcfa3007a64d061e4 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 6 Oct 2026 14:57:23 +0000 Subject: [PATCH] fix(dart): link generic calls the parser reads as two comparisons (#2375) tree-sitter-dart parses a generic call with arguments as two comparisons about as often as it parses it as a call, depending on the code around it: `ref.read(p)` comes out as `relational_expression(relational_expression(ref.read < Repo) > (p))`. Generic constructors (`Provider((ref) => 0)`), named arguments (which then parse as a `record_literal`) and calls after `await` or a prefix operator are hit too. Neither extractor recorded a call for that shape, only a member read of `ref.read` that linked nothing, so providers, repository reads and bloc lookups written with a type argument had no callers. Across felangel/bloc, rrousselGit/riverpod and flutter/samples there are 2,853 such calls, against 2,836 generic calls with arguments that parse as calls. - Both extractors (the TS walker and the native kernel) recover the call at its `<` when the type argument names a type and the code is laid out as a call: `<` against the callee and `(` against the `>`. A comparison such as `a < b` is left alone. The recovered call gets what a parsed one does: the call at the `<`, a reference to the type argument, and no member read of the callee or of a prefixed type argument. - A call chained on a recovered call keeps its bare name. The `BlocProvider.of().increment` encoding a parsed chain gets resolves through what `of` returns, which for a generic factory is its type parameter, so it would drop the edge the bare name finds. - When the grammar ends an arrow function in front of a generic call (`(ref) => ref.watch(p)` as `((ref) => ref).watch(p)`), the receiver is read from the end of the arrow's body, so the call is `ref.watch` rather than a bare `watch`. Validated with the kernel loaded: bloc +85, riverpod +1,580 and flutter/samples +54 edges, none lost; kernel/wasm parity has 0 diffs on all three repos. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/dart-generic-calls.test.ts | 219 +++++++++++++ .../kernel-parity/TortureGenericCalls.dart | 62 ++++ __tests__/kernel-dart-parity.test.ts | 8 +- codegraph-kernel/src/dart.rs | 290 ++++++++++++++---- docs/design/dart-kernel-port-checklist.md | 41 +++ src/extraction/languages/dart.ts | 235 ++++++++++---- src/extraction/tree-sitter-types.ts | 5 +- src/extraction/tree-sitter.ts | 30 +- 9 files changed, 764 insertions(+), 127 deletions(-) create mode 100644 __tests__/dart-generic-calls.test.ts create mode 100644 __tests__/fixtures/kernel-parity/TortureGenericCalls.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6a11232..16d4ed8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, an import whose name contains a `$`, like `import items$ from './store'`, `import * as api$ from './api'` or `import { load as load$ } from './data'`, now links the calls made through it, such as `items$.getState().inc()`, to the code it imports, and so does a `$` name in a `require(…)` destructuring or an `export { … } from` barrel. Before, such an import was skipped or bound under a cut-off name (`load` for `load$`), so those calls reached nothing and the functions they call could look unused. A local variable or parameter that reuses an imported `$` name, like `const items$ = …` inside a function, is no longer mistaken for the import. Re-index JavaScript and TypeScript projects after upgrading. - In Dart, calling a parameter or a local variable, like `create(ref, arg)` inside `overrideWith(… create)`, a closure's parameter, a loop variable or a test's mock `listener(0, 1)`, no longer links the call to an unrelated function or constant of the same name in another file, or to a method the class inherits: a Bloc event handler's `emit(…)` is the handler's own `emit` parameter, not `Bloc.emit`. So `codegraph callers` and impact no longer list code that never calls them, while calls the parameter or variable doesn't cover, and calls to a local function, link as before. Re-index Dart projects after upgrading. - In Dart, `const` constructors and redirecting factories, like flutter_bloc's `const BlocProvider.value(…)` or freezed's `const factory LoginState.loading() = _Loading;`, are now indexed, so a call such as `BlocProvider.value(…)` or `BlocProvider.value(…)` links to that constructor instead of another class's member with the same name. A call through a class name now reaches only that class's own constructors and static members, never a look-alike class, a parent class, an instance getter of the same name, or a project method that merely shares its name with an SDK call like `Uri.parse(…)`, and a constructor is no longer linked from a bare name, the end of a call chain or an annotation like `@internal`. Re-index Dart projects after upgrading. +- In Dart, a call that passes a type argument and arguments, like `ref.read(repoProvider)`, `ref.watch(counterProvider)`, `Provider((ref) => …)`, `BlocProvider.of(context)` or `RepositoryProvider(create: …)`, now links to the method or class it calls, and the type it passes counts as used there. Depending on the code around it, the parser often read such a call as two comparisons, so it linked nothing, and Riverpod providers, repository reads and bloc lookups were missing from `callers`, impact and `codegraph_explore`. A real comparison like `a < b` is still left alone. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-generic-calls.test.ts b/__tests__/dart-generic-calls.test.ts new file mode 100644 index 000000000..d106b0774 --- /dev/null +++ b/__tests__/dart-generic-calls.test.ts @@ -0,0 +1,219 @@ +/** + * Dart generic calls that tree-sitter-dart parses as two comparisons. + * + * `ref.read(repoProvider)` comes out of the grammar as + * `(ref.read < Repo) > (repoProvider)`, a relational expression inside + * another, about as often as it comes out as a call: which one depends on the + * code around it. `Provider((ref) => 0)`, `int g(Ref ref) => + * ref.read(1);`, `RepositoryProvider(create: …)` (named arguments + * parse as a record) and `await users.fetch(1)` all can. Neither + * extractor recorded a call for that shape, only a member read of `ref.read` + * that links nothing, so providers, repository reads and bloc lookups written + * with a type argument had no callers. `context.read()`, with + * empty arguments, parses as a call and always worked. + * + * The nest is the call when its type argument names a type and it is laid + * out as a call is, `<` against the callee and `(` against the `>`; a + * comparison is written `a < b`. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import type { Node } from '../src/types'; + +const BACKENDS = ['default', 'wasm'] as const; + +let kernel: string | undefined; + +function useBackend(backend: (typeof BACKENDS)[number]): void { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; +} + +beforeEach(() => { + kernel = process.env.CODEGRAPH_KERNEL; +}); + +afterEach(() => { + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; +}); + +describe('Dart generic calls parsed as comparisons: extraction', () => { + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['dart']); + }); + + const SOURCE = [ + 'int g(Ref ref) => ref.read(1);', + '', + 'void f(Ref ref) {', + ' final a = ref.watch(p);', + ' ref.read(repoProvider);', + ' print(ref.watch(p) + 1);', + ' final b = a < B > (c);', + ' final d = a(c);', + '}', + '', + ].join('\n'); + + it.each(BACKENDS)('records the call a parsed one gets, at its `<` (%s)', (backend) => { + useBackend(backend); + const result = extractFromSource('lib/providers.dart', SOURCE, 'dart'); + const names = new Map(result.nodes.map((n) => [n.id, n.name])); + const refs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls' || r.referenceKind === 'references') + .map((r) => `${names.get(r.fromNodeId)} ${r.referenceKind} ${r.referenceName} ${r.line}:${r.column}`) + .sort(); + + expect(refs).toEqual([ + 'f calls print 6:7', + // Line 6 parses as a call: what the recovered calls must look like. + 'f calls ref.read 5:10', + 'f calls ref.watch 4:21', + 'f calls ref.watch 6:17', + 'f references Ref 3:7', + // The type argument is referenced as a parsed one is; `int` is built in. + 'f references Repo 5:11', + 'g calls ref.read 1:26', + 'g references Ref 1:6', + ]); + // No member read of `ref.read` / `ref.watch`, and the comparisons on + // lines 7 and 8 stay comparisons. + }); +}); + +describe('Dart generic calls parsed as comparisons: graph', () => { + const FILES: Record = { + 'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', + 'lib/riverpod.dart': `class Ref { + T watch(Object provider) => throw 0; + T read(Object provider) => throw 0; +} + +class Provider { + Provider(T Function(Ref ref) create); +} +`, + 'lib/data.dart': `class User {} + +class Repo { + Repo(); +} + +class UserRepository { + Future fetch(int id) => throw 0; +} +`, + 'lib/bloc.dart': `class BuildContext {} + +class BlocProvider { + static T of(BuildContext context) => throw 0; +} + +class CounterCubit { + void increment() {} +} + +class RepositoryProvider { + RepositoryProvider({required T Function(BuildContext context) create}); +} +`, + // Every call below parses as two comparisons. + 'lib/app.dart': `import 'bloc.dart'; +import 'data.dart'; +import 'riverpod.dart'; + +final seedProvider = Provider((ref) => 0); +final repoProvider = Provider((ref) => Repo()); +final countProvider = Provider((Ref ref) => ref.watch(seedProvider)); + +int g(Ref ref) => ref.read(countProvider); + +Repo readRepo(Ref ref) { + final repo = ref.read(repoProvider); + return repo; +} + +Future loadUser(UserRepository users) async { + final user = await users.fetch(1); + return user; +} + +void tap(BuildContext context) { + BlocProvider.of(context).increment(); +} + +Object provide() => RepositoryProvider(create: (_) => Repo()); + +bool compare(int a, int b, int c) { + final spaced = a < b > (c); + final lower = a(c); + return spaced && lower; +} +`, + }; + + let root = ''; + let cg: CodeGraph | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-generic-calls-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(BACKENDS)('link the calls, constructions and type arguments (%s)', async (backend) => { + useBackend(backend); + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const node = (qualifiedName: string): Node => { + const found = graph.getNodesInFile('lib/app.dart').find((n) => n.qualifiedName === qualifiedName); + expect(found, qualifiedName).toBeDefined(); + return found!; + }; + const targets = (from: string, kind: string): string[] => + [...new Set(graph + .getOutgoingEdgesFrom([node(from).id]) + .filter((e) => e.kind === kind) + .map((e) => graph.getNode(e.target)!.qualifiedName))] + .sort(); + + // The issue's shapes: a typed receiver's method, through `=>` and a block. + expect(targets('g', 'calls')).toEqual(['Ref::read']); + expect(targets('readRepo', 'calls')).toEqual(['Ref::read']); + expect(targets('readRepo', 'references')).toContain('Repo'); + + // Riverpod providers built with a type argument construct their provider, + // and a typed closure parameter still types the call written in it. + expect(targets('seedProvider', 'instantiates')).toEqual(['Provider']); + expect(targets('repoProvider', 'instantiates')).toEqual(['Provider', 'Repo']); + expect(targets('countProvider', 'instantiates')).toEqual(['Provider']); + expect(targets('countProvider', 'calls')).toEqual(['Ref::watch']); + + // After `await`, with named arguments, and through a static method. + expect(targets('loadUser', 'calls')).toEqual(['UserRepository::fetch']); + expect(targets('provide', 'instantiates')).toEqual(['Repo', 'RepositoryProvider']); + expect(targets('tap', 'calls')).toEqual(['BlocProvider::of', 'CounterCubit::increment']); + expect(targets('tap', 'references')).toContain('CounterCubit'); + + // Comparisons are not calls. + expect(targets('compare', 'calls')).toEqual([]); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/TortureGenericCalls.dart b/__tests__/fixtures/kernel-parity/TortureGenericCalls.dart new file mode 100644 index 000000000..7c09f1b98 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureGenericCalls.dart @@ -0,0 +1,62 @@ +// Generic calls tree-sitter-dart parses as two comparisons — `ref.read(p)` +// comes out as `(ref.read < Repo) > (p)` — recovered as calls, and the +// comparisons that look like them. +import 'package:app/app.dart' as p; + +// Initializers: a constant's, a top-level variable's, a field's. +final counterProvider = Provider((ref) => ref.watch(seedProvider)); +final repoProvider = Provider((ref) { + final dio = ref.read(dioProvider); + return Repo(dio); +}); +var cache = Cache(capacity: 10); +final routes = [MaterialPageRoute(builder: (_) => const Home())]; + +int g(Ref ref) => ref.read(1); + +class Screen { + final notifier = ValueNotifier(0); + static final route = MaterialPageRoute(builder: (_) => const Screen()); + static var shared = Notifier(repoProvider); + + const Screen(); + + Future load(Ref ref, BuildContext context) async { + ref.read(repoProvider); + final user = await ref.read(userRepoProvider).fetch(id); + final map = ref.read>(mapProvider); + final prefixed = ref.read(repoProvider); + final generic = ref.read>(boxProvider); + BlocProvider.of(context).increment(); + final state = BlocProvider.of(context).state; + final neg = -ref.read(countProvider); + final not = !ref.read(flagProvider); + final nested = Wrapper(Inner(seed)); + final bare = build(seed); + final dyn = await box.openBox('box'); + final chained = Factory.create(seed).build(); + final lower = repo.items.where(isDone).toList(); + final self = this.read(repoProvider); + final cascade = Builder(seed)..name = 'x'; + final templ = '${ref.read(repoProvider)}'; + final sum = ref.watch(countProvider) + 1; + return show(context: context, builder: (_) => const Screen()); + } + + void outer(Ref ref) { + int inner() => ref.watch(innerProvider); + final cb = () => navigator.push(any()); + inner(); + } + + bool compare(int a, int b, int c, int d) { + // Comparisons, not calls. + final spaced = a < B > (c); + final lower = a(c); + final left = a (c); + final right = a (c); + final expr = a < b + 1 > (c); + final record = a<(B, C)>(d); + return a < b && c > d; + } +} diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index 7943d3477..c6b248775 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -27,7 +27,9 @@ * second variable left to its function; TortureConstCtors.dart: `const` * constructors and redirecting factories as methods with the unnamed ones * skipped, every redirect-target form, and constructors called with type - * arguments, as statements and as expressions) and their CRLF variants + * arguments, as statements and as expressions; TortureGenericCalls.dart: + * generic calls parsed as two comparisons, recovered as calls) and their CRLF + * variants * (derived in-memory — #1329), plus defer and generated-file pins. * * The full-repo sweeps live in scripts/kernel-parity.mjs (shelf/bloc/flutter @@ -129,6 +131,10 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => { // ones skipped), every redirect-target form, and constructors called // with type arguments in statements and expressions. ['TortureConstCtors.dart', 20], + // Generic calls the grammar parses as two comparisons (`ref.read(p)` + // as `(ref.read < Repo) > (p)`): every callee, type-argument and argument + // shape, in bodies and initializers, and the comparisons that look alike. + ['TortureGenericCalls.dart', 10], ] as const; for (const [file, minNodes] of FIXTURES) { diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs index 458ecc5c4..116414fc6 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -108,6 +108,18 @@ fn is_bodiless_ctor(kind: &str) -> bool { matches!(kind, "constant_constructor_signature" | "redirecting_factory_constructor_signature") } +/// DART_LOWERCASE_TYPES (dart.ts) — Dart's lowercase built-in types, which a +/// type argument can name: ``, ``. +fn is_lowercase_type(name: &str) -> bool { + matches!(name, "int" | "double" | "num" | "bool" | "dynamic" | "void") +} + +/// DART_PREFIXED (dart.ts) — what a callee can sit under in front of a +/// generic call: `await`, `-`, `!`. +fn is_prefixed(kind: &str) -> bool { + matches!(kind, "unary_expression" | "await_expression") +} + struct Scope { row: u32, kind: &'static str, @@ -1048,61 +1060,15 @@ impl<'t> Walker<'t> { return None; } let prev = node.prev_named_sibling()?; - if prev.kind() == "identifier" { - return Some(self.text(prev).to_string()); - } - if prev.kind() == "selector" { - let mut pc = prev.walk(); - let accessor = prev.named_children(&mut pc).find(|c| { - matches!( - c.kind(), - "unconditional_assignable_selector" | "conditional_assignable_selector" - ) - }); - if let Some(accessor) = accessor { - let mut ac = accessor.walk(); - let method_id = accessor.named_children(&mut ac).find(|c| c.kind() == "identifier"); - if let Some(method_id) = method_id { - let accessor_prev = prev.prev_named_sibling(); - if let Some(ap) = accessor_prev { - if ap.kind() == "identifier" { - return Some(format!("{}.{}", self.text(ap), self.text(method_id))); - } - // A constructor called with type arguments names - // its type all the same (`BlocProvider.value`). - if let Some(type_name) = self.type_arguments_receiver(ap) { - return Some(format!("{}.{}", type_name, self.text(method_id))); - } - // Chained static-factory: the receiver is itself - // a call — re-encode `().` when - // the chain starts capitalized (#750). - if ap.kind() == "selector" { - let mut apc = ap.walk(); - if ap.named_children(&mut apc).any(|c| c.kind() == "argument_part") { - if let Some(inner) = self.callee_of_arg_part(ap) { - if starts_upper_re().is_match(&inner) { - return Some(format!("{}().{}", inner, self.text(method_id))); - } - } - } - } - } - return Some(self.text(method_id).to_string()); - } - } - } - // super.method() / this.method(): prev is a bare accessor. - if matches!( - prev.kind(), - "unconditional_assignable_selector" | "conditional_assignable_selector" - ) { - let mut pc = prev.walk(); - let id = prev.named_children(&mut pc).find(|c| c.kind() == "identifier"); - if let Some(id) = id { - return Some(self.text(id).to_string()); - } - } - return None; + return self.callee_name(prev); + } + + // A generic call the grammar read as two comparisons + // (`ref.read(p)`), named at its `<`, where a parsed call's + // argument part starts. + if node.kind() == "relational_operator" { + let (callee, _) = self.misparsed_generic_call(node)?; + return self.callee_name(callee); } // new_expression arm — DEAD in practice (the INSTANTIATION branch @@ -1147,6 +1113,189 @@ impl<'t> Walker<'t> { None } + /// dartReceiverOf (dart.ts) — the receiver of the member access + /// `selector`: the node in front of it, or the end of an arrow function's + /// body when the grammar ended the arrow in front of a generic call + /// (`(ref) => ref.watch(p)` → `((ref) => ref).watch(p)`). + fn receiver_of(&self, selector: Node<'t>) -> Option> { + let prev = selector.prev_named_sibling(); + let body = prev + .filter(|p| p.kind() == "function_expression") + .and_then(|p| p.named_child(p.named_child_count().checked_sub(1)?)); + match body { + Some(body) if body.kind() == "function_expression_body" => { + body.named_child(body.named_child_count().checked_sub(1)?) + } + _ => prev, + } + } + + /// dartCallee (dart.ts) — the callee name of the call whose argument part + /// follows `prev`. + fn callee_name(&self, prev: Node<'t>) -> Option { + if prev.kind() == "identifier" { + return Some(self.text(prev).to_string()); + } + if prev.kind() == "selector" { + let mut pc = prev.walk(); + let accessor = prev.named_children(&mut pc).find(|c| { + matches!( + c.kind(), + "unconditional_assignable_selector" | "conditional_assignable_selector" + ) + }); + if let Some(accessor) = accessor { + let mut ac = accessor.walk(); + let method_id = accessor.named_children(&mut ac).find(|c| c.kind() == "identifier"); + if let Some(method_id) = method_id { + let accessor_prev = self.receiver_of(prev); + if let Some(ap) = accessor_prev { + if ap.kind() == "identifier" { + return Some(format!("{}.{}", self.text(ap), self.text(method_id))); + } + // A constructor called with type arguments names + // its type all the same (`BlocProvider.value`). + if let Some(type_name) = self.type_arguments_receiver(ap) { + return Some(format!("{}.{}", type_name, self.text(method_id))); + } + // Chained static-factory: the receiver is itself + // a call — re-encode `().` when + // the chain starts capitalized (#750). + if ap.kind() == "selector" { + let mut apc = ap.walk(); + if ap.named_children(&mut apc).any(|c| c.kind() == "argument_part") { + if let Some(inner) = self.callee_of_arg_part(ap) { + if starts_upper_re().is_match(&inner) { + return Some(format!("{}().{}", inner, self.text(method_id))); + } + } + } + } + } + return Some(self.text(method_id).to_string()); + } + } + } + // super.method() / this.method(): prev is a bare accessor. + if matches!( + prev.kind(), + "unconditional_assignable_selector" | "conditional_assignable_selector" + ) { + let mut pc = prev.walk(); + let id = prev.named_children(&mut pc).find(|c| c.kind() == "identifier"); + if let Some(id) = id { + return Some(self.text(id).to_string()); + } + } + None + } + + /// dartMisparsedGenericCall (dart.ts) — a generic call tree-sitter-dart + /// parsed as two comparisons, `(ref.read < Repo) > (p)`, found from its + /// `<`: the node its callee ends with (under any `await` or prefix + /// operator) and the identifier naming its type argument. Recovered when + /// the type argument names a type and the code is laid out as a call — + /// `<` against the callee, `(` against the `>`. A call chained on it keeps + /// its bare name (the `X().m` encoding resolves through a generic + /// factory's type parameter and finds nothing). + fn misparsed_generic_call(&self, lt: Node<'t>) -> Option<(Node<'t>, Node<'t>)> { + if lt.kind() != "relational_operator" || lt.child(0)?.kind() != "<" { + return None; + } + let inner = lt.parent()?; + let outer = inner.parent()?; + if inner.kind() != "relational_expression" || outer.kind() != "relational_expression" { + return None; + } + let left = outer.named_child(0)?; + if left.start_byte() != inner.start_byte() || left.end_byte() != inner.end_byte() { + return None; + } + let gt = outer.named_child(1)?; + if gt.kind() != "relational_operator" || gt.child(0)?.kind() != ">" { + return None; + } + let args = outer.named_child(2)?; + if !matches!(args.kind(), "parenthesized_expression" | "record_literal") || args.start_byte() != gt.end_byte() { + return None; + } + let before = lt.prev_named_sibling()?; + if before.end_byte() != lt.start_byte() { + return None; + } + // The type argument: `T`, `p.T`, `T<…>` or `p.T<…>`, and nothing else. + let head = lt.next_named_sibling()?; + if head.kind() != "identifier" { + return None; + } + let mut type_name = head; + let mut rest = head.next_named_sibling(); + if let Some(sel) = rest { + let accessor = sel.named_child(0).filter(|a| a.kind() == "unconditional_assignable_selector"); + if sel.kind() == "selector" { + if let Some(accessor) = accessor { + let mut ac = accessor.walk(); + let name = accessor.named_children(&mut ac).find(|c| c.kind() == "identifier"); + type_name = name?; + rest = sel.next_named_sibling(); + } + } + } + if let Some(sel) = rest { + let only_type_args = sel.named_child_count() == 1 + && sel.named_child(0).map(|t| t.kind() == "type_arguments").unwrap_or(false); + if sel.kind() == "selector" && only_type_args { + rest = sel.next_named_sibling(); + } + } + if rest.is_some() { + return None; + } + let name = self.text(type_name); + if !is_upper_camel(name) && !is_lowercase_type(name) { + return None; + } + // `await repo.load(id)` and `-x.size(y)` put the callee + // under the prefix. + let mut callee = before; + while is_prefixed(callee.kind()) { + let count = callee.named_child_count(); + callee = callee.named_child(count.checked_sub(1)?)?; + } + Some((callee, type_name)) + } + + /// dartInMisparsedGenericCall (dart.ts) — whether a member access + /// (`.member` on `receiver`, followed by `next` under `parent`) belongs to + /// a misparsed generic call: its callee is called, and a prefixed type + /// argument (`p.Repo`) names a type. + fn in_misparsed_generic_call( + &self, + receiver: Node<'t>, + next: Option>, + parent: Option>, + ) -> bool { + let mut after = next; + let mut up = parent; + while after.is_none() { + let Some(u) = up.filter(|u| is_prefixed(u.kind())) else { break }; + after = u.next_named_sibling(); + up = if after.is_some() { None } else { u.parent() }; + } + if let Some(a) = after { + if a.kind() == "relational_operator" && self.misparsed_generic_call(a).is_some() { + return true; + } + } + if parent.map(|p| p.kind()) != Some("relational_expression") { + return false; + } + receiver + .prev_named_sibling() + .map(|b| b.kind() == "relational_operator" && self.misparsed_generic_call(b).is_some()) + .unwrap_or(false) + } + /// dartCalleeOfArgPart (dart.ts:100-116). fn callee_of_arg_part(&self, arg_part: Node<'t>) -> Option { let prev = arg_part.prev_named_sibling()?; @@ -1213,8 +1362,15 @@ impl<'t> Walker<'t> { if node.named_children(&mut cursor).any(|c| c.kind() == "argument_part") { return; } - let Some(prev) = node.prev_named_sibling() else { return }; + let Some(prev) = self.receiver_of(node) else { return }; if prev.kind() == "identifier" && cap_ident_re().is_match(self.text(prev)) { + // `Map` in `x.read>(y)` parsed as comparisons is a type + // argument, which the body walker references as a type. + if let Some(before) = prev.prev_named_sibling() { + if before.kind() == "relational_operator" && self.misparsed_generic_call(before).is_some() { + return; + } + } let name = self.text(prev).to_string(); // NO callee-of-call skip — `ConfigT.load()` double-emits // (references + calls). Position = the IDENTIFIER (receiver). @@ -1510,7 +1666,8 @@ impl<'t> Walker<'t> { if receiver.kind() != "identifier" { return; } - if let Some(next) = node.next_named_sibling() { + let next = node.next_named_sibling(); + if let Some(next) = next { if next.kind() == "selector" { let mut nc = next.walk(); if next.named_children(&mut nc).any(|c| c.kind() == "argument_part") { @@ -1518,6 +1675,11 @@ impl<'t> Walker<'t> { } } } + // The callee of a generic call the grammar read as comparisons + // (`ref.read(p)`) is called; a prefixed type argument names a type. + if self.in_misparsed_generic_call(receiver, next, node.parent()) { + return; + } let name = format!("{}.{}", self.text(receiver), self.text(member)); let reader = self.top_row(); self.push_ref_at(reader, &name, "references", member); @@ -1544,12 +1706,18 @@ impl<'t> Walker<'t> { self.extract_static_member_ref(node); - // A getter read `x.area` (#2338); a type the body names (#2327). + // A getter read `x.area` (#2338); a type the body names (#2327) — the + // type argument of a generic call parsed as comparisons included. self.extract_member_read(node); - if kind == "type_identifier" && self.is_type_name_dart(node) { - let name = self.text(node).to_string(); + let type_node = match kind { + "type_identifier" => Some(node), + "relational_operator" => self.misparsed_generic_call(node).map(|(_, type_name)| type_name), + _ => None, + }; + if let Some(type_node) = type_node.filter(|t| self.is_type_name_dart(*t)) { + let name = self.text(type_node).to_string(); let owner = self.top_row(); - self.push_ref_at(owner, &name, "references", node); + self.push_ref_at(owner, &name, "references", type_node); } if kind == "function_signature" { diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index e2575d6ef..7f243c1ab 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -701,6 +701,7 @@ extract-mini.txt): | `BlocProviderT.value(…);` as a statement | args selector; prev = `.value` selector; accessorPrev = a selector holding only `type_arguments`, whose prev is the identifier | `BlocProviderT.value` (dartTypeArgumentsReceiver, since 2026-10-06 — bare `value` before); the `.value().tail` re-encode reads it the same way | | `=> BlocProviderT.value(…)`, and as an initializer, argument or `return` value | `constructor_invocation` (type_identifier [`.` type_identifier] type_arguments `.` identifier arguments) | `BlocProviderT.value` at the node position — the LAST type_identifier, so `p.X.named()` → `X.named` (since 2026-10-06; nothing before) | | `generic(5)` | args selector (type args ride argument_part) | bare `generic` | +| `ref.read(p)`, `Provider((ref) => 0)`, `X(name: v)` parsed as two comparisons, `(ref.read < Repo) > (p)` (§Misparsed generic calls) | the `<` relational_operator (dartMisparsedGenericCall) | the parsed call's ref — `ref.read` / `Provider` / `X`, at the `<` | | `await fetch()` | recursion through unary/await_expression | `fetch` at the selector | | `throw StateError('bad')` | recursion | `StateError` | | `'sum ${a + compute()}'` | template_substitution recursion | `compute` (interpolation calls EMIT); `$name` → identifier_dollar_escaped → nothing | @@ -712,6 +713,46 @@ extractCall (:3684), LITERAL_RECEIVER_TYPES (:373-388), SKIP_RECEIVERS, the parenthesized-conversion regex (:4530), template-strip — ALL UNREACHABLE for dart (callTypes empty). Do not port them. +#### Misparsed generic calls (dartMisparsedGenericCall / misparsed_generic_call) + +tree-sitter-dart parses a generic call with arguments as two comparisons about +as often as it parses it as a call — the GLR tie goes either way with the code +around it: `final a = ref.watch(p);`, `ref.read(p);`, `int g(Ref +ref) => ref.read(1);` and `Provider((ref) => 0)` come out as +`relational_expression(relational_expression( < ) > )`, +while `print(ref.watch(p) + 1)`, `obj.a.b(p)` and +`ref.read(a, b)` parse as calls (2,853 nests vs 2,836 parsed generic +calls with arguments across bloc, riverpod and flutter/samples). The args are +a `parenthesized_expression`, or a `record_literal` when named +(`DropdownButton(items: …)`); the type part is `identifier`, a `.Name` +selector (`p.Repo`) and/or a type_arguments-only selector (`Map`); the +callee part sits under `unary_expression` / `await_expression` for `await` and +prefix operators. Recovered at the `<` when the type names a type (`/^[_$]*[A-Z]/` +or `int double num bool dynamic void`), `<` touches the callee and `(` touches +the `>`; `a < B > (c)`, `a(c)` and `a (c)` stay comparisons. The +recovered form emits what the parsed call does: + +- `calls` at the `<` (the parsed call's argument_part starts there); +- `references ` for the type argument, via isDartTypeName (§Type refs); + the static-member path skips `Map` in `>` so it is not doubled; +- no member read of the callee (`ref.read`, also under `await`) or of a + prefixed type (`p.Repo`). + +A call chained on a recovered one keeps its bare name (`increment` in +`BlocProvider.of(context).increment()`), unlike the parsed +chain's `BlocProvider.of().increment`: that encoding resolves through `of`'s +return type, which for generic factories is the type parameter, so it would +lose the edge the bare name finds. + +The grammar can also end an arrow function in front of a generic call: +`(ref) => ref.watch(p)` → `((ref) => ref).watch(p)`, in the +relational and the selector form alike. dartReceiverOf / receiver_of read a +receiver in that place from the end of the arrow's body (Dart writes a member +access on an arrow itself in parentheses), for callee names and static-member +refs: `ref.watch`, `BlocProvider.of`. dartCalleeOfArgPart / callee_of_arg_part +do not, so `() => BlocProvider.of(context).increment()` keeps +`increment` bare, for the reason above. + ### Static-member / value-read refs — dart branch (:4759-4767), STATIC_MEMBER_LANGS:346 Called from the body walker only (:5218). The DART-SPECIFIC branch (the diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index fdac302a7..788d3392a 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -63,14 +63,130 @@ function isDartFieldOrTopLevelEntry(node: SyntaxNode): boolean { return owner?.type === 'declaration' && owner.parent !== null && DART_MEMBER_BODIES.has(owner.parent.type); } +/** + * The receiver of the member access `selector`: the node in front of it, or + * the end of an arrow function's body when the grammar ended the arrow in + * front of a generic call — `(ref) => ref.watch(p)` comes out as + * `((ref) => ref).watch(p)`. Dart writes a member access on the arrow + * itself in parentheses, so a receiver in that place is never the arrow. It + * names the call (`ref.watch`) and a static member's class; a call chained on + * that call keeps its bare name (see dartMisparsedGenericCall). + */ +export function dartReceiverOf(selector: SyntaxNode): SyntaxNode | null { + const prev = selector.previousNamedSibling; + const body = prev?.type === 'function_expression' ? prev.lastNamedChild : null; + return body?.type === 'function_expression_body' ? body.lastNamedChild : prev; +} + +/** Dart's lowercase built-in types, which a type argument can name: ``, ``. */ +const DART_LOWERCASE_TYPES: ReadonlySet = new Set(['int', 'double', 'num', 'bool', 'dynamic', 'void']); + +/** What a callee can sit under in front of a generic call: `await`, `-`, `!`. */ +const DART_PREFIXED: ReadonlySet = new Set(['unary_expression', 'await_expression']); + +/** A generic call tree-sitter-dart parsed as two comparisons (dartMisparsedGenericCall). */ +interface DartMisparsedCall { + /** The node the callee ends with: what a parsed call's argument part follows. */ + callee: SyntaxNode; + /** The identifier naming the type argument (`Repo` in `` and ``). */ + typeName: SyntaxNode; +} + +/** + * A generic call tree-sitter-dart parsed as two comparisons, found from its + * `<`. `ref.read(repoProvider)`, `Provider((ref) => 0)` and + * `DropdownButton(items: items)` come out as `(ref.read < Repo) > + * (repoProvider)`: a `relational_expression` inside another, with the + * arguments as a `parenthesized_expression`, or a `record_literal` when they + * are named. That happens about as often as the call parses as one, because + * the grammar breaks the tie by the code around it. Dart reads it as a call, + * and so does this when the type argument names a type (`Repo`, `p.Repo`, + * `Map`, `int`, `void`) and the code is laid out as a call: `<` against + * the callee and `(` against the `>`. A comparison is written `a < b`. + * + * A call chained on it keeps the bare name it had (`increment` in + * `BlocProvider.of(context).increment()`) rather than taking + * the `BlocProvider.of().increment` a parsed chain gets: that encoding + * resolves through what `of` returns, and a generic factory like this one + * returns its type parameter, so it would drop the edge the bare name finds. + */ +export function dartMisparsedGenericCall(lt: SyntaxNode): DartMisparsedCall | undefined { + if (lt.type !== 'relational_operator' || lt.firstChild?.type !== '<') return undefined; + const inner = lt.parent; + const outer = inner?.parent; + if (inner?.type !== 'relational_expression' || outer?.type !== 'relational_expression') return undefined; + const left = outer.namedChild(0); + if (left?.startIndex !== inner.startIndex || left.endIndex !== inner.endIndex) return undefined; + const gt = outer.namedChild(1); + if (gt?.type !== 'relational_operator' || gt.firstChild?.type !== '>') return undefined; + const args = outer.namedChild(2); + if (args?.type !== 'parenthesized_expression' && args?.type !== 'record_literal') return undefined; + if (args.startIndex !== gt.endIndex) return undefined; + const before = lt.previousNamedSibling; + if (!before || before.endIndex !== lt.startIndex) return undefined; + // The type argument: `T`, `p.T`, `T<…>` or `p.T<…>`, and nothing else. + const head = lt.nextNamedSibling; + if (head?.type !== 'identifier') return undefined; + let typeName = head; + let rest = head.nextNamedSibling; + if (rest?.type === 'selector' && rest.firstNamedChild?.type === 'unconditional_assignable_selector') { + const name = rest.firstNamedChild.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (!name) return undefined; + typeName = name; + rest = rest.nextNamedSibling; + } + if (rest?.type === 'selector' && rest.namedChildCount === 1 && rest.firstNamedChild?.type === 'type_arguments') { + rest = rest.nextNamedSibling; + } + if (rest) return undefined; + if (!/^[_$]*[A-Z]/.test(typeName.text) && !DART_LOWERCASE_TYPES.has(typeName.text)) return undefined; + // `await repo.load(id)` and `-x.size(y)` put the callee under the prefix. + let callee: SyntaxNode = before; + while (DART_PREFIXED.has(callee.type)) { + const last: SyntaxNode | null = callee.lastNamedChild; + if (!last) return undefined; + callee = last; + } + return { callee, typeName }; +} + +/** + * Whether a member access — `.member` on `receiver`, followed by `next` under + * `parent` — belongs to a misparsed generic call instead of reading a member: + * its callee (`ref.read` in `ref.read(p)`, after an `await` too) is + * called, and the prefixed type argument (`p.Repo` in `x.read(y)`) + * names a type. + */ +function dartInMisparsedGenericCall( + receiver: SyntaxNode, + next: SyntaxNode | null, + parent: SyntaxNode | null, +): boolean { + let after = next; + let up = parent; + while (!after && up && DART_PREFIXED.has(up.type)) { + after = up.nextNamedSibling; + up = after ? null : up.parent; + } + if (after?.type === 'relational_operator' && dartMisparsedGenericCall(after)) return true; + if (parent?.type !== 'relational_expression') return false; + const before = receiver.previousNamedSibling; + return before?.type === 'relational_operator' && dartMisparsedGenericCall(before) !== undefined; +} + /** * A Dart member read — `x.area`, `s?.label`, `Config.instance` — as the ref * `.`, positioned on the member's name. Reading a getter runs * it, so the resolver links the read to a getter, as a call, and to nothing * else (#2338). The receiver must be a plain name, the one shape whose type the - * resolver can look up; a member that is called (`x.grow()`) is the call's. + * resolver can look up; a member that is called (`x.grow()`, or `x.grow(y)` + * parsed as comparisons) is the call's. `parent` is the node's parent when the + * walker has it: reading `.parent` walks down from the root. */ -export function dartMemberRead(node: SyntaxNode): { name: string; node: SyntaxNode } | undefined { +export function dartMemberRead( + node: SyntaxNode, + parent: SyntaxNode | null = node.parent, +): { name: string; node: SyntaxNode } | undefined { if (node.type !== 'selector') return undefined; const accessor = node.namedChildren.find((c: SyntaxNode) => c.type === 'unconditional_assignable_selector' || c.type === 'conditional_assignable_selector' @@ -83,6 +199,7 @@ export function dartMemberRead(node: SyntaxNode): { name: string; node: SyntaxNo if (next?.type === 'selector' && next.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part')) { return undefined; } + if (dartInMisparsedGenericCall(receiver, next, parent)) return undefined; return { name: `${receiver.text}.${member.text}`, node: member }; } @@ -234,6 +351,63 @@ function dartCalleeOfArgPart(argPart: SyntaxNode): string | undefined { return undefined; } +/** + * The callee name of the Dart call whose argument part follows `prev`: `run` + * for `run(…)`, `obj.method` for `obj.method(…)`, `Foo.create().bar` for a + * chain off a capitalized call (#750), and a bare `method` otherwise. + */ +function dartCallee(prev: SyntaxNode): string | undefined { + // Simple function/constructor call: prev is identifier (e.g., runApp(...), MyWidget(...)) + if (prev.type === 'identifier') { + return prev.text; + } + + // Method call: prev is selector with accessor (e.g., obj.method(...), Navigator.push(...)) + if (prev.type === 'selector') { + const accessor = prev.namedChildren.find((c: SyntaxNode) => + c.type === 'unconditional_assignable_selector' || c.type === 'conditional_assignable_selector' + ); + if (accessor) { + const methodId = accessor.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (methodId) { + // Include receiver for first call in chain (receiver is a direct identifier) + const accessorPrev = dartReceiverOf(prev); + if (accessorPrev?.type === 'identifier') { + return accessorPrev.text + '.' + methodId.text; + } + // A constructor called with type arguments names its type all the + // same: `BlocProvider.value(…)` → `BlocProvider.value`. + const typeName = dartTypeArgumentsReceiver(accessorPrev); + if (typeName) return typeName + '.' + methodId.text; + // Chained static-factory / fluent call: the receiver is itself a call + // (`Foo.create().bar()`), so accessorPrev is that call's argument_part + // selector. Encode `().` so resolution can infer + // bar's class from what `Foo.create` RETURNS (#645/#608 mechanism) — + // but only when the chain starts with a capitalized type (a companion + // factory / static method / constructor); an instance chain + // (`obj.foo().bar()`) keeps the bare name (its receiver's type can't + // be recovered here). + if (accessorPrev?.type === 'selector' && + accessorPrev.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part')) { + const innerCallee = dartCalleeOfArgPart(accessorPrev); + if (innerCallee && /^[A-Z]/.test(innerCallee)) { + return `${innerCallee}().${methodId.text}`; + } + } + return methodId.text; + } + } + } + + // super.method() / this.method(): prev is bare unconditional_assignable_selector + if (prev.type === 'unconditional_assignable_selector' || prev.type === 'conditional_assignable_selector') { + const methodId = prev.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (methodId) return methodId.text; + } + + return undefined; +} + export const dartExtractor: LanguageExtractor = { functionTypes: ['function_signature'], classTypes: ['class_definition'], @@ -490,57 +664,14 @@ export const dartExtractor: LanguageExtractor = { if (!hasArgPart) return undefined; const prev = node.previousNamedSibling; - if (!prev) return undefined; + return prev ? dartCallee(prev) : undefined; + } - // Simple function/constructor call: prev is identifier (e.g., runApp(...), MyWidget(...)) - if (prev.type === 'identifier') { - return prev.text; - } - - // Method call: prev is selector with accessor (e.g., obj.method(...), Navigator.push(...)) - if (prev.type === 'selector') { - const accessor = prev.namedChildren.find((c: SyntaxNode) => - c.type === 'unconditional_assignable_selector' || c.type === 'conditional_assignable_selector' - ); - if (accessor) { - const methodId = accessor.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); - if (methodId) { - // Include receiver for first call in chain (receiver is a direct identifier) - const accessorPrev = prev.previousNamedSibling; - if (accessorPrev?.type === 'identifier') { - return accessorPrev.text + '.' + methodId.text; - } - // A constructor called with type arguments names its type all the - // same: `BlocProvider.value(…)` → `BlocProvider.value`. - const typeName = dartTypeArgumentsReceiver(accessorPrev); - if (typeName) return typeName + '.' + methodId.text; - // Chained static-factory / fluent call: the receiver is itself a call - // (`Foo.create().bar()`), so accessorPrev is that call's argument_part - // selector. Encode `().` so resolution can infer - // bar's class from what `Foo.create` RETURNS (#645/#608 mechanism) — - // but only when the chain starts with a capitalized type (a companion - // factory / static method / constructor); an instance chain - // (`obj.foo().bar()`) keeps the bare name (its receiver's type can't - // be recovered here). - if (accessorPrev?.type === 'selector' && - accessorPrev.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part')) { - const innerCallee = dartCalleeOfArgPart(accessorPrev); - if (innerCallee && /^[A-Z]/.test(innerCallee)) { - return `${innerCallee}().${methodId.text}`; - } - } - return methodId.text; - } - } - } - - // super.method() / this.method(): prev is bare unconditional_assignable_selector - if (prev.type === 'unconditional_assignable_selector' || prev.type === 'conditional_assignable_selector') { - const methodId = prev.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); - if (methodId) return methodId.text; - } - - return undefined; + // A generic call the grammar read as two comparisons (`ref.read(p)`), + // named at its `<`, where a parsed call's argument part starts. + if (node.type === 'relational_operator') { + const call = dartMisparsedGenericCall(node); + return call ? dartCallee(call.callee) : undefined; } // new MyWidget() — explicit constructor call diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 1c2d4f374..941e6b36c 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -323,9 +323,10 @@ export interface LanguageExtractor { * getter `area` when `area` is one. Returns the ref name (`x.area`) and the * node to position it on, or undefined. Emitted as a `references` ref from * the function the read is in; the resolver links it only to a getter, with - * a `calls` edge (a plain field read links nothing). + * a `calls` edge (a plain field read links nothing). `parent` is the node's + * parent, handed down by the body walker. */ - extractMemberRead?: (node: SyntaxNode) => { name: string; node: SyntaxNode } | undefined; + extractMemberRead?: (node: SyntaxNode, parent?: SyntaxNode) => { name: string; node: SyntaxNode } | undefined; /** * Node types representing a file-level package/namespace declaration diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8019b3af1..7a4ea76ad 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -23,7 +23,7 @@ import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; import { rustImplTypeName } from './languages/rust'; -import { isDartTypeName, pushDartTypeRefs } from './languages/dart'; +import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -5783,8 +5783,12 @@ export class TreeSitterExtractor { if (this.language === 'dart') { if (node.type !== 'selector') return; if (node.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part')) return; - const prev = node.previousNamedSibling; + const prev = dartReceiverOf(node); if (prev?.type === 'identifier' && /^[A-Z][A-Za-z0-9_]*$/.test(prev.text)) { + // `Map` in `x.read>(y)` parsed as comparisons is a type + // argument, which the body walker references as a type. + const before = knownParent?.type === 'relational_expression' ? prev.previousNamedSibling : null; + if (before?.type === 'relational_operator' && dartMisparsedGenericCall(before)) return; this.pushStaticMemberRef(prev.text, ownerId, prev); } return; @@ -6600,7 +6604,7 @@ export class TreeSitterExtractor { // A member read that may run code — Dart's `x.area` calls the getter // `area` (#2338). The resolver links it to a getter, as a call, or to // nothing: a plain field read stays a reference that names no symbol. - const read = this.extractor!.extractMemberRead?.(node); + const read = this.extractor!.extractMemberRead?.(node, parent); if (read) { const readerId = this.nodeStack[this.nodeStack.length - 1]; if (readerId) { @@ -6615,18 +6619,22 @@ export class TreeSitterExtractor { } // A type a Dart body names — a local's declared type, a generic argument - // (`Future.value(null)`, `context.read()`), a cast, a - // type test — is the function's dependency, as a TS local's annotation - // is just below (#2327). - if (this.language === 'dart' && nodeType === 'type_identifier' && isDartTypeName(node)) { + // (`Future.value(null)`, `context.read()`, and + // `ref.read(p)` when the grammar read that call as comparisons), + // a cast, a type test — is the function's dependency, as a TS local's + // annotation is just below (#2327). + if (this.language === 'dart') { + const typeNode = nodeType === 'type_identifier' ? node + : nodeType === 'relational_operator' ? dartMisparsedGenericCall(node)?.typeName + : undefined; const ownerId = this.nodeStack[this.nodeStack.length - 1]; - if (ownerId) { + if (typeNode && ownerId && isDartTypeName(typeNode)) { this.unresolvedReferences.push({ fromNodeId: ownerId, - referenceName: getNodeText(node, this.source), + referenceName: getNodeText(typeNode, this.source), referenceKind: 'references', - line: node.startPosition.row + 1, - column: node.startPosition.column, + line: typeNode.startPosition.row + 1, + column: typeNode.startPosition.column, }); } }