mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-23 10:21:40 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user