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:
Colby Mchenry
2026-10-06 16:29:29 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0c860c00b3
commit 9924684899
12 changed files with 610 additions and 19 deletions
+12 -3
View File
@@ -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).
+37
View File
@@ -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")
);
}
}