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<Repo>(p)` comes out as
`relational_expression(relational_expression(ref.read < Repo) > (p))`.
Generic constructors (`Provider<int>((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<int>(p)` as `((ref) => ref).watch<int>(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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 14:57:23 +00:00
committed by GitHub
parent 652c36b72a
commit 3f96f80fcb
9 changed files with 764 additions and 127 deletions
+1
View File
@@ -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<CounterCubit>.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<Repo>(repoProvider)`, `ref.watch<int>(counterProvider)`, `Provider<User>((ref) => …)`, `BlocProvider.of<CounterCubit>(context)` or `RepositoryProvider<Repo>(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
+219
View File
@@ -0,0 +1,219 @@
/**
* Dart generic calls that tree-sitter-dart parses as two comparisons.
*
* `ref.read<Repo>(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<int>((ref) => 0)`, `int g(Ref ref) =>
* ref.read<int>(1);`, `RepositoryProvider<Repo>(create: …)` (named arguments
* parse as a record) and `await users.fetch<User>(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<CounterBloc>()`, 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<int>(1);',
'',
'void f(Ref ref) {',
' final a = ref.watch<int>(p);',
' ref.read<Repo>(repoProvider);',
' print(ref.watch<int>(p) + 1);',
' final b = a < B > (c);',
' final d = a<b>(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<string, string> = {
'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n',
'lib/riverpod.dart': `class Ref {
T watch<T>(Object provider) => throw 0;
T read<T>(Object provider) => throw 0;
}
class Provider<T> {
Provider(T Function(Ref ref) create);
}
`,
'lib/data.dart': `class User {}
class Repo {
Repo();
}
class UserRepository {
Future<T> fetch<T>(int id) => throw 0;
}
`,
'lib/bloc.dart': `class BuildContext {}
class BlocProvider {
static T of<T>(BuildContext context) => throw 0;
}
class CounterCubit {
void increment() {}
}
class RepositoryProvider<T> {
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<int>((ref) => 0);
final repoProvider = Provider<Repo>((ref) => Repo());
final countProvider = Provider<int>((Ref ref) => ref.watch<int>(seedProvider));
int g(Ref ref) => ref.read<int>(countProvider);
Repo readRepo(Ref ref) {
final repo = ref.read<Repo>(repoProvider);
return repo;
}
Future<User> loadUser(UserRepository users) async {
final user = await users.fetch<User>(1);
return user;
}
void tap(BuildContext context) {
BlocProvider.of<CounterCubit>(context).increment();
}
Object provide() => RepositoryProvider<Repo>(create: (_) => Repo());
bool compare(int a, int b, int c) {
final spaced = a < b > (c);
final lower = a<b>(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([]);
});
});
@@ -0,0 +1,62 @@
// Generic calls tree-sitter-dart parses as two comparisons — `ref.read<Repo>(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<int>((ref) => ref.watch<int>(seedProvider));
final repoProvider = Provider<Repo>((ref) {
final dio = ref.read<Dio>(dioProvider);
return Repo(dio);
});
var cache = Cache<String>(capacity: 10);
final routes = [MaterialPageRoute<void>(builder: (_) => const Home())];
int g(Ref ref) => ref.read<int>(1);
class Screen {
final notifier = ValueNotifier<int>(0);
static final route = MaterialPageRoute<void>(builder: (_) => const Screen());
static var shared = Notifier<Repo>(repoProvider);
const Screen();
Future<void> load(Ref ref, BuildContext context) async {
ref.read<Repo>(repoProvider);
final user = await ref.read<UserRepository>(userRepoProvider).fetch<User>(id);
final map = ref.read<Map<String, Repo>>(mapProvider);
final prefixed = ref.read<p.Repo>(repoProvider);
final generic = ref.read<p.Box<Repo>>(boxProvider);
BlocProvider.of<CounterCubit>(context).increment();
final state = BlocProvider.of<CounterCubit>(context).state;
final neg = -ref.read<int>(countProvider);
final not = !ref.read<bool>(flagProvider);
final nested = Wrapper<A>(Inner<B>(seed));
final bare = build<Repo>(seed);
final dyn = await box.openBox<dynamic>('box');
final chained = Factory.create<Repo>(seed).build();
final lower = repo.items.where<Item>(isDone).toList();
final self = this.read<Repo>(repoProvider);
final cascade = Builder<Repo>(seed)..name = 'x';
final templ = '${ref.read<Repo>(repoProvider)}';
final sum = ref.watch<int>(countProvider) + 1;
return show<void>(context: context, builder: (_) => const Screen());
}
void outer(Ref ref) {
int inner() => ref.watch<int>(innerProvider);
final cb = () => navigator.push<void>(any());
inner();
}
bool compare(int a, int b, int c, int d) {
// Comparisons, not calls.
final spaced = a < B > (c);
final lower = a<b>(c);
final left = a <B>(c);
final right = a<B> (c);
final expr = a < b + 1 > (c);
final record = a<(B, C)>(d);
return a < b && c > d;
}
}
+7 -1
View File
@@ -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<Repo>(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) {
+229 -61
View File
@@ -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: `<int>`, `<void>`.
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<T>.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 `<inner>().<method>` 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<Repo>(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<int>(p)` → `((ref) => ref).watch<int>(p)`).
fn receiver_of(&self, selector: Node<'t>) -> Option<Node<'t>> {
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<String> {
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<T>.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 `<inner>().<method>` 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<User>(id)` and `-x.size<int>(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<Node<'t>>,
parent: Option<Node<'t>>,
) -> 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<String> {
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<Map<K, V>>(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<Repo>(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" {
+41
View File
@@ -701,6 +701,7 @@ extract-mini.txt):
| `BlocProviderT<CounterT>.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<CounterT>.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<T>.named()` → `X.named` (since 2026-10-06; nothing before) |
| `generic<int>(5)` | args selector (type args ride argument_part) | bare `generic` |
| `ref.read<Repo>(p)`, `Provider<int>((ref) => 0)`, `X<T>(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<int>(p);`, `ref.read<Repo>(p);`, `int g(Ref
ref) => ref.read<int>(1);` and `Provider<int>((ref) => 0)` come out as
`relational_expression(relational_expression(<callee> < <type>) > <args>)`,
while `print(ref.watch<int>(p) + 1)`, `obj.a.b<Repo>(p)` and
`ref.read<Repo>(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<String>(items: …)`); the type part is `identifier`, a `.Name`
selector (`p.Repo`) and/or a type_arguments-only selector (`Map<K, V>`); 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<b>(c)` and `a <B>(c)` stay comparisons. The
recovered form emits what the parsed call does:
- `calls` at the `<` (the parsed call's argument_part starts there);
- `references <Type>` for the type argument, via isDartTypeName (§Type refs);
the static-member path skips `Map` in `<Map<K, V>>` 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<CounterCubit>(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<int>(p)` → `((ref) => ref).watch<int>(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<CounterCubit>(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
+183 -52
View File
@@ -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<int>(p)` comes out as
* `((ref) => ref).watch<int>(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: `<int>`, `<void>`. */
const DART_LOWERCASE_TYPES: ReadonlySet<string> = 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<string> = 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 `<Repo>` and `<p.Repo>`). */
typeName: SyntaxNode;
}
/**
* A generic call tree-sitter-dart parsed as two comparisons, found from its
* `<`. `ref.read<Repo>(repoProvider)`, `Provider<int>((ref) => 0)` and
* `DropdownButton<String>(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<K, V>`, `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<CounterCubit>(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<User>(id)` and `-x.size<int>(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<Repo>(p)`, after an `await` too) is
* called, and the prefixed type argument (`p.Repo` in `x.read<p.Repo>(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
* `<receiver>.<member>`, 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<T>(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<CounterCubit>.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 `<innerCallee>().<method>` 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<CounterCubit>.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 `<innerCallee>().<method>` 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<Repo>(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
+3 -2
View File
@@ -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
+19 -11
View File
@@ -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<Map<K, V>>(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<Report?>.value(null)`, `context.read<Report>()`), 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<Report?>.value(null)`, `context.read<Report>()`, and
// `ref.read<Report>(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,
});
}
}