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
+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,
});
}
}