Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- 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.

## [1.6.2] - 2026-10-03

Expand Down
134 changes: 134 additions & 0 deletions __tests__/dart-bodiless-member-docs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* A Dart member with no body — a constructor like `Foo._();` or `Foo.parts()
* : id = 0;`, an abstract `void m();`, an `external` one — parses as a
* `declaration` wrapping its signature, and the member's `///` dartdoc and
* `@annotation`s sit before that wrapper, not before the signature.
*
* Both extractors looked for them before the signature, inside the wrapper,
* where there is nothing. Such a member lost its documentation, and
* `@visibleForTesting Foo._();` emitted no `decorates` reference.
* (flutter_bloc's `const BlocProvider.value(…)`, with its long dartdoc, is one
* of these constructors.)
*
* 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 path from 'path';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
import type { ExtractionResult } from '../src/types';

const KERNEL_PATH = path.join(
__dirname,
'..',
'codegraph-kernel',
'prebuilds',
`${process.platform}-${process.arch}`,
'codegraph-kernel.node'
);
const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1';

const SOURCE = `abstract class Repository {
/// Loads the user with [id].
Future<User> load(String id);

@visibleForTesting
void reset();

/// Calls into the platform.
external int platformVersion();
}

class User {
/// Creates a user from its parts.
User.parts(this.name) : id = 0;

@visibleForTesting
User.test(this.name) : id = 1;

@Deprecated('use parts')
@internal
User.old(this.name) : id = 2;

/// The user's name.
@deprecated
final String name;
User.afterField() : name = '', id = 3;

final int id;
}
`;

const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;

describe('Dart members with no body keep their dartdoc and annotations', () => {
let savedEnv: Record<string, string | undefined> = {};

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['dart']);
});

beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
resetKernelForTests();
});

afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
resetKernelForTests();
});

function extract(backend: 'kernel' | 'wasm', source: string): ExtractionResult {
if (backend === 'wasm') {
process.env.CODEGRAPH_KERNEL = '0';
return extractFromSource('lib/user.dart', source, 'dart');
}
delete process.env.CODEGRAPH_KERNEL;
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
const result = tryKernelExtract('lib/user.dart', source, 'dart');
expect(result, 'kernel extraction').not.toBeNull();
return result!;
}

const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const);

for (const crlf of [false, true]) {
it.each(backends)(`%s${crlf ? ' (CRLF)' : ''}`, (backend) => {
const result = extract(backend, crlf ? SOURCE.replace(/\n/g, '\r\n') : SOURCE);
const node = (qualifiedName: string) => {
const found = result.nodes.find((n) => n.qualifiedName === qualifiedName);
expect(found, qualifiedName).toBeDefined();
return found!;
};
const decorators = (qualifiedName: string): string[] => {
const id = node(qualifiedName).id;
return result.unresolvedReferences
.filter((r) => r.referenceKind === 'decorates' && r.fromNodeId === id)
.map((r) => r.referenceName)
.sort();
};

// The dartdoc before the declaration is the member's.
expect(node('Repository::load').docstring).toBe('Loads the user with [id].');
expect(node('Repository::platformVersion').docstring).toBe('Calls into the platform.');
expect(node('User::parts').docstring).toBe('Creates a user from its parts.');

// So are the annotations, stacked ones included.
expect(decorators('Repository::reset')).toEqual(['visibleForTesting']);
expect(decorators('User::test')).toEqual(['visibleForTesting']);
expect(decorators('User::old')).toEqual(['Deprecated', 'internal']);

// A field's dartdoc and annotations stay with the field, which mints no
// node, rather than moving on to the constructor after it.
expect(node('User::afterField').docstring).toBeUndefined();
expect(decorators('User::afterField')).toEqual([]);
});
}
});
112 changes: 112 additions & 0 deletions __tests__/fixtures/kernel-parity/TortureDeclarationDocs.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Members with no body are a `declaration` wrapping their signature; their
// dartdoc and annotations sit before the wrapper.

abstract class Store {
/// Abstract method doc.
Future<void> save(Object value);

/// Doc above an annotation.
@protected
void flush();

@mustCallSuper
@Deprecated('use flush')
void sync();

/// Abstract getter doc (no node).
int get size;

/// Abstract setter doc (no node).
set size(int v);

/// Abstract operator doc (no node).
Store operator +(Store other);

/** Block dartdoc on an external member. */
external int nativeCount();

/// Static external doc.
external static Store open();

// Plain comment on an abstract member.
bool isEmpty();
}

class Point {
/// Unnamed constructor doc (no node).
Point(this.x, this.y);

/// Named constructor doc.
/// Second line.
Point.origin() : x = 0, y = 0;

/// Redirecting constructor doc.
Point.onX(int x) : this(x, 0);

@visibleForTesting
Point.test(this.x, this.y);

/// Before annotation.
@Deprecated('use origin')
/// After annotation.
Point.zero() : x = 0, y = 0;

/* é */ @visibleForTesting Point.inline(this.x) : y = 0;

/// Field doc.
@observable
final int x;
Point.afterField(this.x, this.y);

@first
Point.prev(this.x, this.y);
Point.next(this.x, this.y);

final int y;

/// Const constructor doc.
@literal
const Point.c(this.x, this.y);

/// Redirecting factory doc.
const factory Point.r(int x, int y) = Point;

/// Bodied named constructor doc.
@visibleForTesting
Point.bodied(this.x, this.y) {
validate();
}

void validate() {}
}

mixin Walker {
/// Mixin abstract doc.

void walk();
}

extension Ext on String {
/// Extension external doc.
@pragma('vm:prefer-inline')
external String shout();
}

extension type Meters(double value) {
/// Extension type external doc.
external double km();
}

enum Mode {
on,
off;

/// Enum const constructor doc (no node).
const Mode();

/// Enum external doc.
external bool get isOn;

/// Enum external method doc.
external void toggle();
}
10 changes: 8 additions & 2 deletions __tests__/kernel-dart-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
* 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; TortureGenericCalls.dart:
* generic calls parsed as two comparisons, recovered as calls) and their CRLF
* variants
* 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.
*
* The full-repo sweeps live in scripts/kernel-parity.mjs (shelf/bloc/flutter
Expand Down Expand Up @@ -135,6 +137,10 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => {
// 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],
// Members with no body (`declaration`-wrapped constructors, abstract and
// external members) take their dartdoc and annotations from before the
// wrapper; a field's stay with the field.
['TortureDeclarationDocs.dart', 20],
] as const;

for (const [file, minNodes] of FIXTURES) {
Expand Down
39 changes: 32 additions & 7 deletions codegraph-kernel/src/dart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,29 @@ impl<'t> Walker<'t> {
false
}

/// getDeclarationWrapper (dart.ts) — a member with no body (`Foo._();`,
/// `const Foo.c();`, an abstract `void m();`) is a `declaration` wrapping
/// its signature, and the member's `///` dartdoc and `@annotation`s come
/// before the wrapper. A signature that opens the declaration takes both
/// from there.
fn declaration_wrapper(&self, node: Node<'t>) -> Option<Node<'t>> {
let parent = node.parent()?;
if parent.kind() != "declaration" {
return None;
}
if parent.named_child(0)? == node {
Some(parent)
} else {
None
}
}

/// docstringFor (tree-sitter.ts) — the preceding comment run, looked up
/// from the declaration wrapper when there is one.
fn docstring_of(&self, node: Node<'t>) -> Option<String> {
preceding_docstring(self.declaration_wrapper(node).unwrap_or(node), self.src)
}

/// resolveBody (dart.ts:158-171).
fn resolve_body(&self, node: Node<'t>) -> Option<Node<'t>> {
if matches!(node.kind(), "function_signature" | "method_signature") {
Expand Down Expand Up @@ -804,7 +827,7 @@ impl<'t> Walker<'t> {
}
return;
}
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
let signature = self.signature_of(node);
let visibility = self.visibility_of(node);
let is_async = self.is_async_of(node);
Expand Down Expand Up @@ -851,7 +874,7 @@ impl<'t> Walker<'t> {
}
return;
}
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
let signature = self.signature_of(node);
let visibility = self.visibility_of(node);
let is_async = self.is_async_of(node);
Expand Down Expand Up @@ -893,7 +916,7 @@ impl<'t> Walker<'t> {
// fallback finds the ON type's type_identifier — a class named after
// the extended type (preserved).
let name = self.extract_name(node);
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
let visibility = self.visibility_of(node);
let row = self.create_node(
"class",
Expand Down Expand Up @@ -924,7 +947,7 @@ impl<'t> Walker<'t> {
None => return,
};
let name = self.extract_name(node);
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
let visibility = self.visibility_of(node);
let row = self.create_node(
"enum",
Expand Down Expand Up @@ -966,7 +989,7 @@ impl<'t> Walker<'t> {
if name == "<anonymous>" {
return false;
}
let docstring = preceding_docstring(node, self.src);
let docstring = self.docstring_of(node);
// `value` field is null (type_alias has no fields) → no refs from
// the aliased type; returns false → children re-visited.
self.create_node("type_alias", &name, node, Extra { docstring, ..Default::default() });
Expand Down Expand Up @@ -1397,8 +1420,10 @@ impl<'t> Walker<'t> {
}
// Scan 2: preceding siblings, backward, stop at the first
// non-annotation — stacked annotations emit in REVERSE source order.
if let Some(parent) = decl.parent() {
let decl_start = decl.start_byte();
// A `declaration`-wrapped member's annotations precede the wrapper.
let anchor = self.declaration_wrapper(decl).unwrap_or(decl);
if let Some(parent) = anchor.parent() {
let decl_start = anchor.start_byte();
let mut decl_idx: Option<usize> = None;
for i in 0..parent.named_child_count() {
if let Some(sib) = parent.named_child(i) {
Expand Down
Loading