mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(dart): a member keeps the dartdoc written above its annotations (#2387)
A Dart member's annotations are siblings that stand 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 (or its `declaration` wrapper) and stopped at the first node
that was not a comment, so the annotation ended the run before the `///`
was reached. Members behind `@override`, `@protected`, `@mustCallSuper`,
`@pragma(...)`, `@internal` and the like lost their documentation, and
searching for words in it didn't find them.
Extraction (TS + kernel, at parity):
- getPrecedingDocstring takes an optional list of sibling types to step
over, and Dart sets `docstringStepOverTypes: ['annotation']`. Stacked,
multi-line and inline annotations are passed, and comments on either
side of one still join (`/// a` `@x` `// b` gives "a" and "b" on two
lines), as adjacent comments already did. Any other node still ends
the walk: the previous member's body, a field, a top-level variable,
an import or another declaration.
- The kernel's shared docstring.rs gains preceding_docstring_stepping_over.
preceding_docstring passes an empty list, so every other language is
unchanged; only Dart's docstring_of passes ["annotation"].
- Class-like declarations needed nothing: a class, mixin, extension,
extension type, enum or typedef node starts at its first annotation,
so the dartdoc above it is already the node's previous sibling.
Validated on felangel/bloc @b9be1e2 and rrousselGit/riverpod @4ba1be2 at
main 249d9a86 (and at 1b752de2) with the kernel loaded: docstrings bloc
+26, riverpod +1,072 with 9 changed and none removed. Nodes otherwise,
edges, refs and files are byte-identical, full-index kernel and wasm
dumps are byte-identical, and the kernel parity sweeps show 0 diffs.
Not changed: a comment between an annotation and the declaration still
hides the annotation from the decorator scan. Recording those would only
add wrong edges until Dart `decorates` resolution stops preferring
methods: riverpod's `@riverpod` edges land on an analyzer getter rather
than `const riverpod`.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// A member's annotations sit between it and the dartdoc written above them;
|
||||
// the docstring walk steps over them. A class-like declaration opens with its
|
||||
// annotations, so the dartdoc above them precedes its node.
|
||||
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
@visibleForTesting
|
||||
void afterImport() {}
|
||||
|
||||
/// Top function doc.
|
||||
@pragma('vm:entry-point')
|
||||
void top() {}
|
||||
|
||||
/// External top doc.
|
||||
@pragma('vm:external-name', 'f')
|
||||
external int extTop();
|
||||
|
||||
/// Stacked, prefixed and generic annotations.
|
||||
@meta.immutable
|
||||
@JsonSerializable<Foo>(explicitToJson: true)
|
||||
@Deprecated('x')
|
||||
void stacked() {}
|
||||
|
||||
/// Top getter doc (no node).
|
||||
@x
|
||||
int get topGetter => 1;
|
||||
|
||||
/** Block dartdoc above an annotation. */
|
||||
@x
|
||||
void blockDoc() {}
|
||||
|
||||
// Plain comment above an annotation.
|
||||
@x
|
||||
void plainAbove() {}
|
||||
|
||||
/* Inline block */ @x void inlineBlock() {}
|
||||
|
||||
/// a
|
||||
@x
|
||||
// b
|
||||
@y
|
||||
/* c */
|
||||
void interleaved() {}
|
||||
|
||||
/// Doc, then blank lines.
|
||||
|
||||
@x
|
||||
|
||||
void blanks() {}
|
||||
|
||||
/// A variable's doc.
|
||||
final answer = 42;
|
||||
@x
|
||||
void afterVariable() {}
|
||||
|
||||
const kLimit = 3;
|
||||
@x
|
||||
void afterConst() {}
|
||||
|
||||
class C {
|
||||
/// Operator doc.
|
||||
@override
|
||||
bool operator ==(Object other) => true;
|
||||
|
||||
/// Factory doc.
|
||||
@x
|
||||
factory C.make() => C._();
|
||||
|
||||
/// Bodied constructor doc.
|
||||
@x
|
||||
C.named() {}
|
||||
|
||||
/// Static method doc.
|
||||
@x
|
||||
static void s() {}
|
||||
|
||||
/// External member doc.
|
||||
@x
|
||||
external void extMember();
|
||||
|
||||
/// Bodiless member doc.
|
||||
@override
|
||||
void bodiless();
|
||||
|
||||
/// Const constructor doc.
|
||||
@literal
|
||||
const C.c();
|
||||
|
||||
/// Redirecting factory doc.
|
||||
@x
|
||||
const factory C.r() = C.c;
|
||||
|
||||
/// Static field doc.
|
||||
@x
|
||||
static const int k = 1;
|
||||
@x
|
||||
void afterStaticField() {}
|
||||
|
||||
/// Multi-line annotation.
|
||||
@Deprecated(
|
||||
'Use other. '
|
||||
'Removed in 3.0',
|
||||
)
|
||||
void multi() {}
|
||||
|
||||
@override // ignore: must_call_super
|
||||
void trailingComment() {}
|
||||
|
||||
/// Doc above, ignore below.
|
||||
@override
|
||||
// ignore: invalid_use_of_protected_member
|
||||
void ignoreBelow() {}
|
||||
|
||||
@x
|
||||
/// Doc below an annotation.
|
||||
void docBelow() {}
|
||||
|
||||
/// Field doc.
|
||||
@JsonKey(name: 'x')
|
||||
final int field = 0;
|
||||
@override
|
||||
void afterField() {}
|
||||
|
||||
void before() {} // trailing comment
|
||||
@override
|
||||
void afterBody() {}
|
||||
|
||||
/// Getter doc.
|
||||
@override
|
||||
int get value => 1;
|
||||
|
||||
/// Setter doc.
|
||||
@override
|
||||
set value(int v) {}
|
||||
|
||||
C._();
|
||||
}
|
||||
|
||||
/// Class doc above a comment.
|
||||
@immutable
|
||||
// ignore: something
|
||||
class D {}
|
||||
|
||||
@immutable
|
||||
/// Class doc below an annotation.
|
||||
class E {}
|
||||
|
||||
/// Mixin doc.
|
||||
@internal
|
||||
mixin M on C {
|
||||
/// Mixin member doc.
|
||||
@mustCallSuper
|
||||
void m() {}
|
||||
}
|
||||
|
||||
/// Extension doc.
|
||||
@internal
|
||||
extension X on C {
|
||||
/// Extension member doc.
|
||||
@pragma('vm:prefer-inline')
|
||||
void e() {}
|
||||
}
|
||||
|
||||
/// Extension type doc.
|
||||
@internal
|
||||
extension type Meters(double value) {
|
||||
/// Extension type member doc.
|
||||
@redeclare
|
||||
double km() => value / 1000;
|
||||
}
|
||||
|
||||
/// Enum doc.
|
||||
@JsonEnum()
|
||||
enum Mode {
|
||||
@JsonValue('on')
|
||||
on,
|
||||
off;
|
||||
|
||||
/// Enum member doc.
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
|
||||
/// Typedef doc.
|
||||
@internal
|
||||
typedef Cb = void Function();
|
||||
|
||||
void outer() {
|
||||
/// Local function doc.
|
||||
@pragma('vm:prefer-inline')
|
||||
void local() {}
|
||||
local();
|
||||
}
|
||||
@@ -16,7 +16,7 @@ int blockDoc() => 1;
|
||||
// Plain comment doc.
|
||||
String plainDoc() => 'x';
|
||||
|
||||
/// Broken by annotation.
|
||||
/// Kept past the annotation.
|
||||
@deprecated
|
||||
void annotated() {}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
|
||||
* unresolved refs compared as canonicalized multisets — over the checked-in
|
||||
* fixtures (torture.dart: the master inventory — imports incl. deferred
|
||||
* invisibility, dartdoc in all three comment forms with the
|
||||
* annotation-broken chain, stacked annotations in reverse order, the
|
||||
* invisibility, dartdoc in all three comment forms and above an
|
||||
* annotation, stacked annotations in reverse order, the
|
||||
* static_final_declaration constants hook, the full ctor set with the
|
||||
* unnamed-ctor skip and named-ctor renaming, operator methods as
|
||||
* `<anonymous>`, the extractBareCall matrix incl. cascade invisibility and
|
||||
@@ -32,8 +32,10 @@
|
||||
* TortureDeclarationDocs.dart: the dartdoc and annotations of a
|
||||
* `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.
|
||||
* by every sibling step; TortureAnnotatedDocs.dart: the dartdoc above a
|
||||
* member's annotations, read past them, and what still ends the walk) 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
|
||||
@@ -146,6 +148,11 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => {
|
||||
// dartdoc, block comments, one between a member and its arguments — are
|
||||
// skipped by every sibling step: calls, chains, reads, static references.
|
||||
['TortureCommentChains.dart', 10],
|
||||
// The dartdoc above a member's annotations (stacked, multi-line, inline,
|
||||
// with comments between them) is read past them; a field, a variable, an
|
||||
// import or the previous member's body still ends the walk. Class-like
|
||||
// declarations open with their annotations.
|
||||
['TortureAnnotatedDocs.dart', 40],
|
||||
] as const;
|
||||
|
||||
for (const [file, minNodes] of FIXTURES) {
|
||||
|
||||
@@ -36,7 +36,7 @@ use crate::buffers::{
|
||||
RefRow, StrRef, Tables, FLAG_IS_ASYNC, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE,
|
||||
NONE, NONE_STR,
|
||||
};
|
||||
use crate::docstring::preceding_docstring;
|
||||
use crate::docstring::preceding_docstring_stepping_over;
|
||||
use crate::ids;
|
||||
use crate::textutil as util;
|
||||
use regex::Regex;
|
||||
@@ -46,6 +46,11 @@ use tree_sitter::{Node, Parser};
|
||||
|
||||
const MAX_VALUE_REF_NODES: usize = 20_000;
|
||||
|
||||
/// docstringStepOverTypes (dart.ts) — a member's annotations stand between it
|
||||
/// and the dartdoc written above them (`/// Builds the widget.` `@override`
|
||||
/// `Widget build(…)`), so the docstring walk steps over them.
|
||||
const DOCSTRING_STEP_OVER: &[&str] = &["annotation"];
|
||||
|
||||
/// NAME_STOPLIST (function-ref.ts).
|
||||
fn is_stoplisted(name: &str) -> bool {
|
||||
matches!(
|
||||
@@ -625,9 +630,13 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
|
||||
/// docstringFor (tree-sitter.ts) — the preceding comment run, looked up
|
||||
/// from the declaration wrapper when there is one.
|
||||
/// from the declaration wrapper when there is one, past annotations.
|
||||
fn docstring_of(&self, node: Node<'t>) -> Option<String> {
|
||||
preceding_docstring(self.declaration_wrapper(node).unwrap_or(node), self.src)
|
||||
preceding_docstring_stepping_over(
|
||||
self.declaration_wrapper(node).unwrap_or(node),
|
||||
self.src,
|
||||
DOCSTRING_STEP_OVER,
|
||||
)
|
||||
}
|
||||
|
||||
/// resolveBody (dart.ts:158-171).
|
||||
|
||||
@@ -131,6 +131,18 @@ pub fn clean_comment_markers(comment: &str) -> String {
|
||||
/// Returns None when there is no preceding comment (a PRESENT-but-empty
|
||||
/// docstring after cleaning still returns Some(""), matching the TS helper).
|
||||
pub fn preceding_docstring(node: Node, src: &str) -> Option<String> {
|
||||
preceding_docstring_stepping_over(node, src, &[])
|
||||
}
|
||||
|
||||
/// getPrecedingDocstring's `stepOver` — sibling kinds that may stand between
|
||||
/// the declaration and its comments without ending the run (Dart's
|
||||
/// `annotation`: `/// Builds it.` `@override` `Widget build(…)`). They are not
|
||||
/// part of the docstring; the comments on either side of one still join.
|
||||
pub fn preceding_docstring_stepping_over(
|
||||
node: Node,
|
||||
src: &str,
|
||||
step_over: &[&str],
|
||||
) -> Option<String> {
|
||||
let mut anchor = node;
|
||||
while let Some(parent) = anchor.parent() {
|
||||
if is_wrapper(parent.kind()) {
|
||||
@@ -146,6 +158,8 @@ pub fn preceding_docstring(node: Node, src: &str) -> Option<String> {
|
||||
if is_comment(s.kind()) {
|
||||
comments.push(&src[s.byte_range()]);
|
||||
sibling = s.prev_named_sibling();
|
||||
} else if step_over.contains(&s.kind()) {
|
||||
sibling = s.prev_named_sibling();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -194,4 +208,27 @@ mod tests {
|
||||
"a\r\nb"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `step_over` kind is passed without ending the run, and the comments
|
||||
/// on either side of it join; any other node still ends it. With no
|
||||
/// step-over kinds the walk stops at the first non-comment, as before.
|
||||
#[test]
|
||||
fn steps_over_only_the_listed_kinds() {
|
||||
let grammar = crate::langs::grammar_for("dart").expect("dart grammar");
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&grammar).unwrap();
|
||||
let src = "void g() {}\n/// a\n@x\n// b\nvoid f() {}\n";
|
||||
let tree = parser.parse(src, None).unwrap();
|
||||
let root = tree.root_node();
|
||||
let f = (0..root.named_child_count())
|
||||
.filter_map(|i| root.named_child(i))
|
||||
.filter(|n| n.kind() == "function_signature")
|
||||
.nth(1)
|
||||
.expect("f's signature");
|
||||
assert_eq!(preceding_docstring(f, src).as_deref(), Some("b"));
|
||||
assert_eq!(
|
||||
preceding_docstring_stepping_over(f, src, &["annotation"]).as_deref(),
|
||||
Some("a\nb")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,14 +855,21 @@ BUILTIN_TYPES (:5768-5782), at each leaf's position. Consequences (pinned):
|
||||
Dart annotations are `annotation` (with `name:` field; args form has an
|
||||
`arguments` child) or `marker_annotation`-free — probed: both `@override`
|
||||
(bare) and `@Deprecated('x')` (args) are node type `annotation`, PRECEDING
|
||||
SIBLINGS of the declaration they decorate (inside program / class_body).
|
||||
SIBLINGS of the function or method they decorate (inside program /
|
||||
class_body); a class-like declaration's annotations open its own node
|
||||
instead (scan #1).
|
||||
extractDecoratorsFor (:4897) is called for classes (:1710), functions
|
||||
(:1599), methods (:1819) — NOT for hook-minted constants, enums(!), or
|
||||
type aliases (extractEnum/extractTypeAlias never call it — an annotated
|
||||
enum emits nothing). Mechanics for dart:
|
||||
|
||||
- Scan #1 (direct children :4976-4988): annotations are never children of
|
||||
the signature → inert (no `modifiers` node either).
|
||||
the signature → inert for functions and methods (no `modifiers` node
|
||||
either). A class-like declaration is different: class, mixin, extension
|
||||
and extension type nodes START at their first annotation, which is the
|
||||
node's own leading child, so scan #1 emits it (`@immutable class Foo` →
|
||||
decorates `immutable`). Enums and typedefs are built the same way, but
|
||||
nothing scans them.
|
||||
- Scan #2 (preceding siblings :5002-5023): walk BACKWARD from the
|
||||
declaration; `annotation` is in the accepted set (:5017); stop at the
|
||||
first non-annotation sibling. consider(): target = first namedChild of
|
||||
@@ -876,8 +883,24 @@ enum emits nothing). Mechanics for dart:
|
||||
`@Deprecated('x')\n@pragma('vm:entry-point')\nvoid f()` → decorates
|
||||
`pragma` FIRST, then `Deprecated` (pinned).
|
||||
- For a method: the previous member's function_body (or any declaration)
|
||||
breaks the chain correctly. The annotation-BETWEEN-doc-and-decl also
|
||||
breaks the DOCSTRING chain (§Docstrings).
|
||||
breaks the chain correctly. (The docstring walk steps OVER annotations
|
||||
since 2026-10-06 — §Docstrings.)
|
||||
- **A comment between an annotation and the declaration ends the scan**, so
|
||||
the annotation above it attaches to nothing: `@override` `// ignore:
|
||||
must_call_super` `void f()`, `@x` `/// Doc below.` `void f()` (pinned in
|
||||
TortureAnnotatedDocs.dart). Measured 2026-10-06: bloc @b9be1e2 hides 7
|
||||
annotations this way (all `@override`), riverpod @4ba1be2 55 on 49
|
||||
declarations (`@override` 33, `@riverpod` 16 in the lint fixtures,
|
||||
`@internal` 6). Left as is ON PURPOSE: stepping over comments here is
|
||||
right for Dart (an annotation always belongs to the next declaration), but
|
||||
Dart nodes store no decorator list, so the only effect would be edges, and
|
||||
`override`/`internal` resolve to nothing, while `decorates` scoring prefers
|
||||
function/method candidates (name-matcher.ts), so `@riverpod` lands on the
|
||||
analyzer getter `RiverpodAnnotatedAnnotatedNodeOfX::riverpod` (0.4)
|
||||
instead of `const riverpod = Riverpod()`. That is already true of
|
||||
riverpod's 506 existing `@riverpod` edges. Fix that ranking for Dart (a
|
||||
const or a class, never a method) first, then add `comment` /
|
||||
`documentation_comment` to scan #2's step-over in both arms.
|
||||
- Members with no body (declaration-wrapped ctors, const ctors, redirecting
|
||||
factories, abstract and `external` members): the signature's PARENT is the
|
||||
`declaration`, so until 2026-10-06 the backward scan ran over the
|
||||
@@ -908,10 +931,55 @@ shared code, port nothing**. DOCSTRING_WRAPPER_TYPES contains no dart kinds
|
||||
getDeclarationWrapper's `declaration` for a member with no body (until
|
||||
2026-10-06 those looked inside the wrapper and found nothing, e.g.
|
||||
`const BlocProvider.value(…)`'s dartdoc; validation added 32 docstrings on
|
||||
bloc and 126 on riverpod, none changed or removed). **An `annotation` between the comment run and the
|
||||
declaration BREAKS the chain** (pinned: `/// Broken by annotation.`
|
||||
`@deprecated` `void annotated()` → doc undefined — the dominant real-world
|
||||
loss since `@override` is ubiquitous). Docstrings attach to: functions,
|
||||
bloc and 126 on riverpod, none changed or removed).
|
||||
|
||||
**The walk steps OVER `annotation` siblings** (since 2026-10-06). Until then
|
||||
an annotation between the comment run and the declaration ended the walk:
|
||||
`/// Builds it.` `@override` `Widget build(…)` lost its dartdoc, the
|
||||
dominant real-world loss since `@override` is everywhere. Now dart.ts sets
|
||||
`docstringStepOverTypes: ['annotation']`, which docstringFor hands to
|
||||
getPrecedingDocstring's `stepOver`. The kernel's `docstring_of` calls
|
||||
docstring.rs's `preceding_docstring_stepping_over(…, &["annotation"])`.
|
||||
`preceding_docstring` and every other language keep the empty list.
|
||||
- Stepped over: stacked annotations, multi-line ones (`@Deprecated(` …
|
||||
`)`), inline ones (`/* c */ @x void f()`, freezed's `@optionalTypeArgs
|
||||
TResult maybeMap…` on the declaration line), and blank lines, which are
|
||||
not nodes. This works from a `declaration` wrapper too.
|
||||
- Comments on either side of an annotation keep the join semantics:
|
||||
`/// a` `@x` `// b` `void f()` → `a\nb` (was `b`), and `/// Doc.`
|
||||
`@override` `// ignore: …` → `Doc.\nignore: …`.
|
||||
- Still ends the walk: any other named sibling, such as the previous
|
||||
member's `function_body`, a field's or a bodiless member's `declaration`,
|
||||
a top-level variable (`static_final_declaration_list` /
|
||||
`initialized_identifier_list`), an `import_or_export` or `part_directive`,
|
||||
or a class-like declaration. A comment trailing the previous member on its
|
||||
line (`void a() {} // note`) is collected, as it already was for a member
|
||||
with no annotation.
|
||||
- Class-like declarations needed nothing. A class, mixin, extension,
|
||||
extension type, enum or typedef node starts at its first annotation
|
||||
(§Decorators), so the dartdoc above the annotations is the node's previous
|
||||
sibling and was always kept. A comment written BETWEEN such an annotation
|
||||
and the keyword (`@JsonEnum(…)` `/// The state…` `enum LinterRuleState`)
|
||||
is inside the node and is NOT read (pinned). bloc has 1 such dartdoc,
|
||||
riverpod none (23 `// ignore:` lines and 1 plain comment). A member's
|
||||
comment below its annotation IS read: it is the signature's previous
|
||||
sibling.
|
||||
- Local functions get no docstring with or without annotations: the
|
||||
signature sits in `local_function_declaration > lambda_expression`, with
|
||||
no previous sibling.
|
||||
- Validation (bloc @b9be1e2 / riverpod @4ba1be2, 3f96f80f plus the
|
||||
declaration-wrapper fix, kernel loaded): docstrings +26 / +1,072, changed
|
||||
0 / 9 (each a `// ignore:` line below the annotation, now joined after
|
||||
the dartdoc above), removed 0. Everything else in the dumps (nodes,
|
||||
edges, refs, files) is byte-identical; kernel and wasm full-index dumps
|
||||
are byte-identical; the parity sweeps show 0 diffs. bloc's 26 are all
|
||||
hand-written dartdoc, mostly behind `@mustCallSuper` / `@override`. Of
|
||||
riverpod's 1,072, 667 runs hold a dartdoc (401 in generated
|
||||
`.freezed.dart` / `.g.dart` files, 266 hand-written) and 405 are plain
|
||||
comments only (`// ignore: riverpod_lint/…` in the lint fixtures, the
|
||||
website's snippet markers).
|
||||
|
||||
Docstrings attach to: functions,
|
||||
methods (incl. `<anonymous>` operators), classes/mixins/extensions, enums,
|
||||
type aliases. NOT to: hook-minted constants (extra carries only signature —
|
||||
pinned drop), enum members, imports, the file node. No comment-gluing into
|
||||
@@ -1152,7 +1220,7 @@ NO unwrap, NO ungatedModes, NO addressOfOnly. Pins:
|
||||
`extract-torture.txt` is the expected-output pin). Inventory by branch:
|
||||
imports (dart:, package: with `as`+`show`, export with `hide`,
|
||||
**deferred → invisible**, part → invisible); doc shapes (`///` run,
|
||||
`/** */` kept, `//` kept, annotation-broken chain); annotations (bare,
|
||||
`/** */` kept, `//` kept, a `///` above an annotation); annotations (bare,
|
||||
with-args, stacked → reverse order, on class); top-level constants
|
||||
(CAPS/lowercase/multi/typed/derived, sig truncation ≥100 chars);
|
||||
top-level var/typed-var/getter/setter (all invisible); async vs
|
||||
|
||||
@@ -532,6 +532,13 @@ export const dartExtractor: LanguageExtractor = {
|
||||
if (parent?.type !== 'declaration') return undefined;
|
||||
return parent.firstNamedChild?.equals(node) ? parent : undefined;
|
||||
},
|
||||
// A member's annotations stand between it and the dartdoc written above them
|
||||
// (`/// Builds the widget.` `@override` `Widget build(…)`), so the docstring
|
||||
// walk steps over them instead of stopping there. A class-like declaration
|
||||
// (class, mixin, extension, extension type, enum, typedef) needs none of
|
||||
// this: its annotations open its own node, and the dartdoc above them is
|
||||
// that node's previous sibling.
|
||||
docstringStepOverTypes: ['annotation'],
|
||||
resolveBody: (node, bodyField) => {
|
||||
// Dart: function_body is a next sibling of function_signature/method_signature
|
||||
if (node.type === 'function_signature' || node.type === 'method_signature') {
|
||||
|
||||
@@ -129,8 +129,17 @@ function cleanCommentMarkers(comment: string): string {
|
||||
|
||||
/**
|
||||
* Get the docstring/comment preceding a node
|
||||
*
|
||||
* `stepOver` lists node types that may stand between the comments and the
|
||||
* declaration without ending the run: Dart's annotations, in `/// Builds it.`
|
||||
* `@override` `Widget build(…)`. They are not part of the docstring, and the
|
||||
* comments on either side of one join as if it weren't there. Default: none.
|
||||
*/
|
||||
export function getPrecedingDocstring(node: SyntaxNode, source: string): string | undefined {
|
||||
export function getPrecedingDocstring(
|
||||
node: SyntaxNode,
|
||||
source: string,
|
||||
stepOver: readonly string[] = []
|
||||
): string | undefined {
|
||||
// Climb out of any wrapper(s) so a comment preceding the WHOLE construct
|
||||
// (export-, decorator-, or const-arrow-wrapped) is reachable as a sibling.
|
||||
// The emitted node's own `previousNamedSibling` is empty (export/const) or a
|
||||
@@ -153,6 +162,8 @@ export function getPrecedingDocstring(node: SyntaxNode, source: string): string
|
||||
) {
|
||||
comments.unshift(getNodeText(sibling, source));
|
||||
sibling = sibling.previousNamedSibling;
|
||||
} else if (stepOver.includes(sibling.type)) {
|
||||
sibling = sibling.previousNamedSibling;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,13 @@ export interface LanguageExtractor {
|
||||
* `void m();`) in a `declaration` node. Returns undefined when there is none.
|
||||
*/
|
||||
getDeclarationWrapper?: (node: SyntaxNode) => SyntaxNode | undefined;
|
||||
/**
|
||||
* Node types that may stand between a declaration and the comments written
|
||||
* above it without ending the comment run its docstring is read from. Dart
|
||||
* writes annotations there (`/// Builds it.` `@override` `Widget build(…)`):
|
||||
* each one is stepped over, and comments on either side of it still join.
|
||||
*/
|
||||
docstringStepOverTypes?: string[];
|
||||
/** Extract visibility from node */
|
||||
getVisibility?: (node: SyntaxNode) => 'public' | 'private' | 'protected' | 'internal' | undefined;
|
||||
/** Check if node is exported */
|
||||
|
||||
@@ -563,7 +563,11 @@ export class TreeSitterExtractor {
|
||||
*/
|
||||
private docstringFor(node: SyntaxNode): string | undefined {
|
||||
const anchor = this.extractor?.getDeclarationWrapper?.(node) ?? node;
|
||||
const preceding = getPrecedingDocstring(anchor, this.source);
|
||||
const preceding = getPrecedingDocstring(
|
||||
anchor,
|
||||
this.source,
|
||||
this.extractor?.docstringStepOverTypes
|
||||
);
|
||||
const body = this.extractor?.getBodyDocstring?.(node, this.source);
|
||||
if (preceding && body) return `${preceding}\n\n${body}`;
|
||||
return body || preceding;
|
||||
|
||||
Reference in New Issue
Block a user