diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9fa96c..4e01dace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a constructor or member written without a body, like flutter_bloc's `const BlocProvider.value(…)`, `Foo._();`, `Point.origin() : x = 0, y = 0;` or an abstract or `external` method, now keeps the `///` documentation and the annotations written above it, the way one with a body already did. Before, that documentation was lost, so searching for words in it didn't find the symbol, and an annotation like `@visibleForTesting` or `@protected` on it wasn't recorded. Re-index Dart projects after upgrading. - In Dart, a call on what a generic lookup returns, like flutter_bloc's `BlocProvider.of(context, listen: false).increment()` or Provider's `Provider.of(context, listen: false).add(item)`, now links to that method of the type in the angle brackets, whether the lookup is declared in your project or comes from a package. Before, such a call often linked nothing — for example whenever the lookup passed `listen: false` — so `callers` and impact missed those methods. A lookup that names no type, like `BlocProvider.of(context)`, still links nothing. Re-index Dart projects after upgrading. (#750) - In JavaScript and TypeScript, a store whose name starts or ends with a `$`, like `const items$ = create(…)`, now has its actions indexed when an `export { … }` list on a later line exports it, as in `export { items$ };` or `export { $items as default };`. Before, those actions were missing from the graph, so their callers and impact came back empty. A store whose name only lacks the `$`, like `items`, is no longer treated as exported by that list. Re-index JavaScript and TypeScript projects after upgrading. +- In Dart, a comment inside a chain of calls no longer loses part of it: a call with a comment before its arguments, like `box.grow /* by */ (3)`, now links to the method it calls, and a getter read or a static access written after a comment, like `Config //` followed by `.instance` on the next line (the empty `//` keeps `dart format` from joining the lines), now counts as a use. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-comment-split-chains.test.ts b/__tests__/dart-comment-split-chains.test.ts new file mode 100644 index 00000000..e731d82e --- /dev/null +++ b/__tests__/dart-comment-split-chains.test.ts @@ -0,0 +1,147 @@ +/** + * A comment inside a Dart member chain hid what the chain's parts are. + * + * tree-sitter-dart keeps a comment as a named node wherever it is written, so + * in `tester //` + newline + `.state(…)` — the empty `//` keeps dart format + * from joining the lines, a common idiom in riverpod's tests — the comment, + * not `tester`, sat right before the `.state` selector. Both extractors took + * that node for the receiver: + * + * - a call with a block comment between the member and its arguments + * (`box.grow`, the comment, `(3)`) was lost, and recorded as a read of + * `box.grow`; + * - a getter read (`box //` + `.area`) and a static access (`Config //` + + * `.instance`) written after a comment were lost; + * - a call after a comment kept only its bare name (`state`). The resolver now + * reads a bare chain link back past comments, so that one already linked + * right; it keeps doing so with the receiver in the name. + * + * Every sibling step along a chain now skips comments, so each line extracts + * what it would without the comment. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const FILES: Record = { + 'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', + 'lib/model.dart': `class Counter { + int state(int x) => x; +} + +class Box { + int get area => 1; + int grow(int by) => by; +} + +class Config { + static final Config instance = Config(); +} + +class Foo { + static Foo create() => Foo(); + void bar() {} +} +`, + 'test/scope_test.dart': `import 'package:flutter_test/flutter_test.dart'; +import 'package:app/model.dart'; + +// Same-named methods next to the calls, where a guess by name lands. +class Other { + void bar() {} + int grow(int by) => by; +} + +int inline(Box box) => box.grow /* by */ (3); + +int measure(Box box) { + return box // + .area; +} + +void use() { + final config = Config // + .instance; +} + +void scope(WidgetTester tester) { + tester // + .state(find.byType(Counter)); +} + +void run() { + Foo.create() // + .bar(); +} + +int widen(Box box) { + return box + // a whole-line comment + /// and a dartdoc one + .grow(2); +} +`, +}; + +describe('Dart member chains split by a comment', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-comment-chains-')); + 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); + } + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('extract each part past the comment (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const node = (qualifiedName: string): Node => { + const found = graph.getNodesInFile('test/scope_test.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(); + + // A comment between the member and its arguments: still a call of the + // receiver's method. + expect(targets('inline', 'calls')).toEqual(['Box::grow']); + + // A getter read runs the getter; a static access references its type. + expect(targets('measure', 'calls')).toEqual(['Box::area']); + expect(targets('use', 'references')).toEqual(['Config']); + + // Calls keep resolving with the receiver in the name: `tester.state(…)` is + // WidgetTester's, a type outside the project, not the one `state` the + // project declares; a chain off a factory goes through what `create` + // returns; the receiver's type picks the method past a run of comments. + expect(targets('scope', 'calls')).toEqual([]); + expect(targets('run', 'calls')).toEqual(['Foo::bar', 'Foo::create']); + expect(targets('widen', 'calls')).toEqual(['Box::grow']); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/TortureCommentChains.dart b/__tests__/fixtures/kernel-parity/TortureCommentChains.dart new file mode 100644 index 00000000..f39d4eda --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureCommentChains.dart @@ -0,0 +1,77 @@ +// Comments inside member chains. tree-sitter-dart keeps a comment as a named +// sibling between a chain's parts; every sibling step skips it, so each line +// emits what it would without the comment. +import 'package:flutter_test/flutter_test.dart'; + +class ConfigT { + static final ConfigT instance = ConfigT(); + static ConfigT load() => ConfigT(); + int setting = 0; +} + +class FactoryT { + static FactoryT create() => FactoryT(); + void run() {} +} + +class Box { + int get area => 1; + int grow(int by) => by; +} + +// An initializer, walked for its constant. +final initialized = ConfigT // + .instance; + +class Chains extends Box { + void calls(WidgetTester tester, Box box, List xs) { + // dart format's line-break idiom, a whole-line comment, dartdoc, a run of + // block comments. + tester // + .state(find.byType(Box)); + tester + // a whole-line comment + .pump(); + tester /// doc + .a(); + tester /** doc */ .b(); + tester /* c */ /* d */ .c(); + // A comment between the member and its arguments. + tester.d // c + (); + box.grow /* by */ (3); + // Chains: re-encoded off a capitalized call, bare off a lowercase one. + FactoryT.create() // + .run(); + FactoryT // + .create() // + .run(); + xs // + .map((e) => e) // + .toList(); + // `this` / `super` keep the bare name. + this // + .grow(1); + super // + .grow(2); + } + + int reads(Box box, WidgetTester tester) { + final v = box // + .area; + final w = box.area /* c */ .isEven; + final s = ConfigT // + .instance; + ConfigT // static call: a call and a static reference + .load(); + final t = ConfigT /* setting */ .instance.setting; + // A generic call the grammar reads as two comparisons. + final st = tester // + .state(find.byType(Box)); + // A comment against the `<`: not laid out as a call, so a read and two + // comparisons, as written. + final cr = tester.read /* c */(box); + final dr = tester.read /** doc */(box); + return v + w.hashCode + s.setting + t + st.hashCode; + } +} diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index 234051ed..6fe57017 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -30,9 +30,10 @@ * arguments, as statements and as expressions; TortureGenericCalls.dart: * generic calls parsed as two comparisons, recovered as calls; * TortureDeclarationDocs.dart: the dartdoc and annotations of a - * `declaration`-wrapped member with no body, read from before the wrapper) - * and their CRLF variants - * (derived in-memory — #1329), plus defer and generated-file pins. + * `declaration`-wrapped member with no body, read from before the wrapper; + * TortureCommentChains.dart: comments between a member chain's parts skipped + * by every sibling step) 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 * with --max-deferral 0.3); this suite keeps the invariant alive in @@ -141,6 +142,10 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => { // external members) take their dartdoc and annotations from before the // wrapper; a field's stay with the field. ['TortureDeclarationDocs.dart', 20], + // Comments between a chain's parts — `tester //` + newline + `.state(…)`, + // dartdoc, block comments, one between a member and its arguments — are + // skipped by every sibling step: calls, chains, reads, static references. + ['TortureCommentChains.dart', 10], ] as const; for (const [file, minNodes] of FIXTURES) { diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs index 8c444524..66426688 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -120,6 +120,31 @@ fn is_prefixed(kind: &str) -> bool { matches!(kind, "unary_expression" | "await_expression") } +/// DART_COMMENTS (dart.ts) — line and block comments, and dartdoc. +fn is_comment(node: Node) -> bool { + matches!(node.kind(), "comment" | "documentation_comment") +} + +/// dartPrevNamed (dart.ts) — the named sibling before `node`, past comments: +/// `tester //` + newline + `.state(…)` puts the comment, not `tester`, right +/// before the `.state` selector. +fn prev_named<'t>(node: Node<'t>) -> Option> { + let mut prev = node.prev_named_sibling(); + while let Some(p) = prev.filter(|p| is_comment(*p)) { + prev = p.prev_named_sibling(); + } + prev +} + +/// dartNextNamed (dart.ts) — the named sibling after `node`, past comments. +fn next_named<'t>(node: Node<'t>) -> Option> { + let mut next = node.next_named_sibling(); + while let Some(n) = next.filter(|n| is_comment(*n)) { + next = n.next_named_sibling(); + } + next +} + struct Scope { row: u32, kind: &'static str, @@ -1082,7 +1107,8 @@ impl<'t> Walker<'t> { if !has_arg_part { return None; } - let prev = node.prev_named_sibling()?; + // Past comments: `box.grow /* by */ (3)` calls `box.grow`. + let prev = prev_named(node)?; return self.callee_name(prev); } @@ -1137,11 +1163,11 @@ impl<'t> Walker<'t> { } /// dartReceiverOf (dart.ts) — the receiver of the member access - /// `selector`: the node in front of it, or the end of an arrow function's - /// body when the grammar ended the arrow in front of a generic call - /// (`(ref) => ref.watch(p)` → `((ref) => ref).watch(p)`). + /// `selector`: the node in front of it, past comments, or the end of an + /// arrow function's body when the grammar ended the arrow in front of a + /// generic call (`(ref) => ref.watch(p)` → `((ref) => ref).watch(p)`). fn receiver_of(&self, selector: Node<'t>) -> Option> { - let prev = selector.prev_named_sibling(); + let prev = prev_named(selector); let body = prev .filter(|p| p.kind() == "function_expression") .and_then(|p| p.named_child(p.named_child_count().checked_sub(1)?)); @@ -1242,8 +1268,10 @@ impl<'t> Walker<'t> { if !matches!(args.kind(), "parenthesized_expression" | "record_literal") || args.start_byte() != gt.end_byte() { return None; } + // A comment against the `<` (`ref.read /* c */(p)`) is a + // sibling of its own, so the callee is not against it. let before = lt.prev_named_sibling()?; - if before.end_byte() != lt.start_byte() { + if is_comment(before) || before.end_byte() != lt.start_byte() { return None; } // The type argument: `T`, `p.T`, `T<…>` or `p.T<…>`, and nothing else. @@ -1321,7 +1349,7 @@ impl<'t> Walker<'t> { /// dartCalleeOfArgPart (dart.ts:100-116). fn callee_of_arg_part(&self, arg_part: Node<'t>) -> Option { - let prev = arg_part.prev_named_sibling()?; + let prev = prev_named(arg_part)?; if prev.kind() == "identifier" { return Some(self.text(prev).to_string()); } @@ -1339,7 +1367,7 @@ impl<'t> Walker<'t> { found }); if let Some(method_id) = method_id { - let accessor_prev = prev.prev_named_sibling(); + let accessor_prev = prev_named(prev); if let Some(ap) = accessor_prev { if ap.kind() == "identifier" { return Some(format!("{}.{}", self.text(ap), self.text(method_id))); @@ -1364,7 +1392,7 @@ impl<'t> Walker<'t> { if selector.named_child(0)?.kind() != "type_arguments" { return None; } - let type_node = selector.prev_named_sibling()?; + let type_node = prev_named(selector)?; if type_node.kind() != "identifier" { return None; } @@ -1687,11 +1715,11 @@ impl<'t> Walker<'t> { found }); let Some(member) = member else { return }; - let Some(receiver) = node.prev_named_sibling() else { return }; + let Some(receiver) = prev_named(node) else { return }; if receiver.kind() != "identifier" { return; } - let next = node.next_named_sibling(); + let next = next_named(node); if let Some(next) = next { if next.kind() == "selector" { let mut nc = next.walk(); diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index 672e41e5..fa802233 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -722,6 +722,18 @@ extract-mini.txt): | local-lambda body `final lam = (int a) { helper(a); }` | function_expression recursed transparently | `helper` attributed to the ENCLOSING function; `lam(5)` → `lam` | | `final p = Provider((ref) => Repo(ref.watch(d)))`, `static var s = make()`, `final _c = Controller();` in a class, `var v = load();` | the hook's walkInitializer (§Constants) | the full matrix, from the constant / the class / the file | | ctor initializers (`: size = seed()`), enum-constant args (`ok(200)`), default param values | never body-walked | NOTHING | +| `tester //` + newline + `.state(x)`, `box.grow /* by */ (3)` | comments are named SIBLINGS between the chain's parts | `tester.state` / `box.grow` — every sibling step skips `comment` and `documentation_comment` (dartPrevNamed / dartNextNamed; kernel `prev_named` / `next_named`), so a comment changes nothing | + +Every sibling step along a member chain goes past comments: the callee before +an argument part, dartReceiverOf / receiver_of, dartCalleeOfArgPart's two, +dartTypeArgumentsReceiver's type, and dartMemberRead's receiver and its +next-selector check. tree-sitter-dart keeps a comment as a named node wherever +it is written, so before (2026-10-06) a comment between a member and its +arguments lost the call and emitted a member read, a getter read or static +access after a comment (`Config //`, dart format's line-break idiom) was lost, +and a call after one kept only its bare name. The misparsed-generic-call +layout checks below still read the raw siblings: a comment there means the +code is not laid out as a call. extractCall (:3684), LITERAL_RECEIVER_TYPES (:373-388), SKIP_RECEIVERS, the parenthesized-conversion regex (:4530), template-strip — ALL UNREACHABLE for @@ -743,7 +755,10 @@ selector (`p.Repo`) and/or a type_arguments-only selector (`Map`); the callee part sits under `unary_expression` / `await_expression` for `await` and prefix operators. Recovered at the `<` when the type names a type (`/^[_$]*[A-Z]/` or `int double num bool dynamic void`), `<` touches the callee and `(` touches -the `>`; `a < B > (c)`, `a(c)` and `a (c)` stay comparisons. The +the `>`; `a < B > (c)`, `a(c)` and `a (c)` stay comparisons, and so does +`a.b /* c */(c)`: the comment is a sibling of its own against the `<`, so +it is rejected as the callee (else the gate passed with no call, and the +comment-skipping member read was suppressed). The recovered form emits what the parsed call does: - `calls` at the `<` (the parsed call's argument_part starts there); @@ -773,8 +788,9 @@ do not, so `() => BlocProvider.of(context).increment()` keeps Called from the body walker only (:5218). The DART-SPECIFIC branch (the shared MEMBER_ACCESS_TYPES path is never reached — it returns first): -node.type === `selector` AND it has NO `argument_part` child AND -previousNamedSibling is an `identifier` matching `/^[A-Z][A-Za-z0-9_]*$/` → +node.type === `selector` AND it has NO `argument_part` child AND the +receiver (dartReceiverOf: the previous named sibling past comments) is an +`identifier` matching `/^[A-Z][A-Za-z0-9_]*$/` → `references ` from the enclosing symbol at the **IDENTIFIER's (receiver's) position** (pushStaticMemberRef :4800). Pins: diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index 7b2228b6..2b74652b 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -63,17 +63,40 @@ function isDartFieldOrTopLevelEntry(node: SyntaxNode): boolean { return owner?.type === 'declaration' && owner.parent !== null && DART_MEMBER_BODIES.has(owner.parent.type); } +/** The comments tree-sitter-dart keeps as named nodes: line and block comments, and dartdoc. */ +const DART_COMMENTS: ReadonlySet = new Set(['comment', 'documentation_comment']); + /** - * The receiver of the member access `selector`: the node in front of it, or - * the end of an arrow function's body when the grammar ended the arrow in - * front of a generic call — `(ref) => ref.watch(p)` comes out as - * `((ref) => ref).watch(p)`. Dart writes a member access on the arrow - * itself in parentheses, so a receiver in that place is never the arrow. It - * names the call (`ref.watch`) and a static member's class; a call chained on - * that call keeps its bare name (see dartMisparsedGenericCall). + * The named sibling before `node`, past any comments. A Dart member chain is + * an identifier followed by sibling selectors, and the grammar keeps a comment + * as a sibling wherever it is written, so in `tester //` + newline + + * `.state(…)` (the empty `//` keeps dart format from joining the lines) the + * comment, not `tester`, is right before the `.state` selector. + */ +function dartPrevNamed(node: SyntaxNode): SyntaxNode | null { + let prev = node.previousNamedSibling; + while (prev && DART_COMMENTS.has(prev.type)) prev = prev.previousNamedSibling; + return prev; +} + +/** The named sibling after `node`, past any comments (see dartPrevNamed). */ +function dartNextNamed(node: SyntaxNode): SyntaxNode | null { + let next = node.nextNamedSibling; + while (next && DART_COMMENTS.has(next.type)) next = next.nextNamedSibling; + return next; +} + +/** + * The receiver of the member access `selector`: the node in front of it, past + * any comments, or the end of an arrow function's body when the grammar ended + * the arrow in front of a generic call — `(ref) => ref.watch(p)` comes + * out as `((ref) => ref).watch(p)`. Dart writes a member access on the + * arrow itself in parentheses, so a receiver in that place is never the + * arrow. It names the call (`ref.watch`) and a static member's class; a call + * chained on that call keeps its bare name (see dartMisparsedGenericCall). */ export function dartReceiverOf(selector: SyntaxNode): SyntaxNode | null { - const prev = selector.previousNamedSibling; + const prev = dartPrevNamed(selector); const body = prev?.type === 'function_expression' ? prev.lastNamedChild : null; return body?.type === 'function_expression_body' ? body.lastNamedChild : prev; } @@ -123,8 +146,10 @@ export function dartMisparsedGenericCall(lt: SyntaxNode): DartMisparsedCall | un const args = outer.namedChild(2); if (args?.type !== 'parenthesized_expression' && args?.type !== 'record_literal') return undefined; if (args.startIndex !== gt.endIndex) return undefined; + // A comment against the `<` (`ref.read /* c */(p)`) is a sibling of + // its own, so the callee is not against it: not laid out as a call. const before = lt.previousNamedSibling; - if (!before || before.endIndex !== lt.startIndex) return undefined; + if (!before || DART_COMMENTS.has(before.type) || 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; @@ -194,9 +219,9 @@ export function dartMemberRead( ); const member = accessor?.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); if (!member) return undefined; - const receiver = node.previousNamedSibling; + const receiver = dartPrevNamed(node); if (receiver?.type !== 'identifier') return undefined; - const next = node.nextNamedSibling; + const next = dartNextNamed(node); if (next?.type === 'selector' && next.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part')) { return undefined; } @@ -322,7 +347,7 @@ function dartTypeArgumentsReceiver(selector: SyntaxNode | null): string | undefi if (selector?.type !== 'selector' || selector.namedChildCount !== 1 || selector.namedChild(0)?.type !== 'type_arguments') { return undefined; } - const type = selector.previousNamedSibling; + const type = dartPrevNamed(selector); return type?.type === 'identifier' ? type.text : undefined; } @@ -333,7 +358,7 @@ function dartTypeArgumentsReceiver(selector: SyntaxNode | null): string | undefi * `Foo.create`, a bare `create`, or `Foo` (constructor) — or undefined. */ function dartCalleeOfArgPart(argPart: SyntaxNode): string | undefined { - const prev = argPart.previousNamedSibling; + const prev = dartPrevNamed(argPart); if (!prev) return undefined; if (prev.type === 'identifier') return prev.text; // bare `Foo()` / `create()` if (prev.type === 'selector') { @@ -342,7 +367,7 @@ function dartCalleeOfArgPart(argPart: SyntaxNode): string | undefined { ); const methodId = accessor?.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); if (methodId) { - const accessorPrev = prev.previousNamedSibling; + const accessorPrev = dartPrevNamed(prev); if (accessorPrev?.type === 'identifier') return accessorPrev.text + '.' + methodId.text; const typeName = dartTypeArgumentsReceiver(accessorPrev); if (typeName) return typeName + '.' + methodId.text; @@ -673,7 +698,8 @@ export const dartExtractor: LanguageExtractor = { const hasArgPart = node.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part'); if (!hasArgPart) return undefined; - const prev = node.previousNamedSibling; + // Past comments: `box.grow /* by */ (3)` calls `box.grow`. + const prev = dartPrevNamed(node); return prev ? dartCallee(prev) : undefined; }