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 @@ -51,6 +51,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- In Dart, a function or method now keeps the `///` documentation written above its annotations, like the doc on a widget's `@override Widget build(…)` or on a member marked `@protected`, `@mustCallSuper`, `@pragma(…)` or `@internal`. Before, the annotation between the documentation and the declaration hid it, so searching for words in it didn't find the symbol. Re-index Dart projects after upgrading.
- In Dart, a call on what a generic lookup returns, like flutter_bloc's `BlocProvider.of<CounterCubit>(context, listen: false).increment()` or Provider's `Provider.of<CartModel>(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.
Expand Down
247 changes: 247 additions & 0 deletions __tests__/dart-annotated-member-docs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* A Dart member's annotations sit between it and the dartdoc written above
* them:
*
* /// Builds the widget.
* @override
* Widget build(BuildContext context) { … }
*
* Both extractors read a docstring from the comments directly before the
* declaration and stopped at the first node that was not a comment, so the
* annotation ended the run before the `///` was reached and the member lost
* its documentation. `@override`, `@protected`, `@mustCallSuper`, `@pragma`,
* `@internal` and `@visibleForTesting` are everywhere in Flutter code.
*
* The walk now steps over annotations, stacked and multi-line ones included,
* and a comment between an annotation and the member joins the dartdoc above
* the way adjacent comments already did. Whatever else comes first (a field,
* the previous member's body, a top-level variable, an import) still ends it.
* A class, mixin, extension, enum or typedef kept its dartdoc already: its
* annotations open its own node, so the dartdoc above them is that node's
* previous sibling.
*
* 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 = `// Copyright 2026 the authors.
import 'package:meta/meta.dart';

@visibleForTesting
void afterImport() {}

/// Runs at startup.
@pragma('vm:entry-point')
void main() {}

/// Calls into the platform.
@pragma('vm:external-name', 'version')
external int platformVersion();

/// Watches the counter.
@riverpod
@Deprecated('Use the generated provider')
int counter(Ref ref) => 0;

/// The answer.
final answer = 42;

@visibleForTesting
void afterVariable() {}

abstract class Repository {
/// Loads the user with [id].
@protected
Future<User> load(String id);
}

/// A counter.
@immutable
class Counter {
/// Starts at zero.
@literal
const Counter.zero() : value = 0;

/// Reads a counter from JSON.
@visibleForTesting
factory Counter.fromJson(Map<String, Object?> json) => const Counter.zero();

/// Builds the widget.
@override
Widget build(BuildContext context) => const Text('0');

/// Releases resources.
///
/// Call it once.
@protected
@mustCallSuper
void dispose() {}

/// The current count.
@override
int get count => value;

/// Use [build] instead.
@Deprecated(
'Use build. '
'Removed in 2.0.',
)

Widget render() => const Text('');

/// Joined with the comment below the annotation.
@protected
// ignore: must_call_super
void joined() {}

/// The cached value.
@JsonKey(name: 'value')
final int value;

@override
void afterField() {}

void before() {}
@override
void afterBody() {}
}

/// A mixin.
@internal
mixin Logging on Counter {}

/// An extension.
@internal
extension CounterX on Counter {}

/// An enum.
@JsonEnum()
enum Mode { light, dark }

/// A typedef.
@internal
typedef Callback = void Function();
@visibleForTesting
void afterTypedef() {}
`;

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

describe('Dart members keep the dartdoc written above their 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/counter.dart', source, 'dart');
}
delete process.env.CODEGRAPH_KERNEL;
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
const result = tryKernelExtract('lib/counter.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 doc = (qualifiedName: string) => node(qualifiedName).docstring;
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();
};

// Top-level functions, an external one and stacked annotations.
expect(doc('main')).toBe('Runs at startup.');
expect(doc('platformVersion')).toBe('Calls into the platform.');
expect(doc('counter')).toBe('Watches the counter.');

// Members: methods, a getter, a factory, a member with no body and a
// `const` constructor (both read from before their `declaration`).
expect(doc('Counter::build')).toBe('Builds the widget.');
expect(doc('Counter::count')).toBe('The current count.');
expect(doc('Counter::fromJson')).toBe('Reads a counter from JSON.');
expect(doc('Repository::load')).toBe('Loads the user with [id].');
expect(doc('Counter::zero')).toBe('Starts at zero.');
expect(doc('Counter::dispose')).toBe('Releases resources.\n\nCall it once.');

// A multi-line annotation, with a blank line before the member.
expect(doc('Counter::render')).toBe('Use [build] instead.');

// A comment between the annotation and the member joins the dartdoc,
// as adjacent comments do.
expect(doc('Counter::joined')).toBe(
'Joined with the comment below the annotation.\nignore: must_call_super'
);

// What comes before the annotations still ends the walk: an import, a
// top-level variable, a field (whose dartdoc and annotation are its
// own), the previous member's body and another declaration.
expect(doc('afterImport')).toBeUndefined();
expect(doc('afterVariable')).toBeUndefined();
expect(doc('Counter::afterField')).toBeUndefined();
expect(doc('Counter::afterBody')).toBeUndefined();
expect(doc('afterTypedef')).toBeUndefined();

// Class-like declarations kept theirs already: the annotations open
// their own node.
expect(doc('Counter')).toBe('A counter.');
expect(doc('Logging')).toBe('A mixin.');
expect(doc('CounterX')).toBe('An extension.');
expect(doc('Mode')).toBe('An enum.');
expect(doc('Callback')).toBe('A typedef.');

// The annotations are still recorded where they were.
expect(decorators('counter')).toEqual(['Deprecated', 'riverpod']);
expect(decorators('Counter::build')).toEqual(['override']);
expect(decorators('Counter::dispose')).toEqual(['mustCallSuper', 'protected']);
expect(decorators('Counter::zero')).toEqual(['literal']);
expect(decorators('Counter::afterField')).toEqual(['override']);
});
}
});
Loading