mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
3f96f80fcb
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>
220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
/**
|
|
* 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([]);
|
|
});
|
|
});
|