big_code_analysis_ast/lang_helpers/doc_comment.rs
1//! Doc-comment recognition by bytes, for the grammars that emit one
2//! comment kind for every comment form (#1597).
3//!
4//! Rust (a `doc_comment` child), Groovy (`groovydoc_comment`) and
5//! Perl (`pod_statement`) mark documentation structurally. Every other
6//! grammar parses `/** … */`, `///` or `---` to the same `comment` node
7//! as a plain comment, so the opening marker is the only evidence, and
8//! each language's own tool decides which markers count: Doxygen, javac,
9//! Kotlin's lexer, JSDoc, PHP's scanner, Roslyn and LDoc disagree at the
10//! edges (`/***`, `////`, `/**/`), and the predicates below follow each
11//! tool rather than one rule for all. The slice they inspect is the
12//! comment node's own span, which never reaches `to_string_lossy`
13//! (grammar-dispatch rule 10): a comment's bytes may be any encoding,
14//! and a marker is ASCII.
15//!
16//! Deliberate deviations, all on the strict side: `////` is never
17//! documentation (tree-sitter-rust and Roslyn reject it; Doxygen's and
18//! javac's lexers accept it, but a line of slashes is a banner, not
19//! prose); the empty `/**/` is never documentation (javac lexes it as
20//! an empty doc comment, which documents nothing); and a doc marker has
21//! to open the comment — a `/**` found later in a comment's text is
22//! text.
23
24use crate::checker::Checker;
25use crate::node::{Ancestors, FoldLane, Node};
26
27/// How a language's tool attaches a doc comment to the declaration after
28/// it (#1613), read from each tool's source rather than its manual
29/// (lesson 98); see [`Checker::DOC_BINDING`]. A forward doc comment that
30/// the binding does not attach documents nothing and is billed as a plain
31/// comment.
32///
33/// The doc comments compared are those of one *stretch*: the comment
34/// siblings between two other siblings, read past the ones
35/// [`skips`](Self::skips) names. Blank rows never split a stretch; none of
36/// the bound tools reads them.
37#[derive(Clone, Copy, Debug)]
38pub struct DocBinding {
39 /// Which doc comments of a stretch the tool attaches.
40 pub kept: KeptDocs,
41 /// A sibling that may sit between a doc comment and its target
42 /// without detaching it, so a stretch reads past it: rustc gives an
43 /// item every outer doc comment above it, through its other
44 /// attributes. A grammar that nests annotations inside the declaration
45 /// has nothing to skip. A skipped node is never a target. The
46 /// [`Ancestors`] are the node's chain.
47 pub skips: for<'a, 'b> fn(&Node<'a>, Ancestors<'a, 'b>) -> bool,
48 /// Whether a doc comment documents what follows it. `false` leaves it
49 /// to where it sits, as in an unbound language: Rust's inner `//!`
50 /// documents the item enclosing it, a doc comment in a macro token
51 /// tree documents whatever the expansion makes of it, and a KDoc
52 /// inside a Kotlin declaration's signature is read by `findDocComment`
53 /// from there. The [`Ancestors`] are the comment's chain.
54 pub forward: for<'a, 'b> fn(&Node<'a>, &[u8], Ancestors<'a, 'b>) -> bool,
55 /// A doc comment that documents an entry of its own and takes with it
56 /// the forward doc comments before it in its stretch, so they document
57 /// even when the stretch ends at no target: Doxygen gives a `\file`
58 /// block the doc blocks it has gathered since the last entry, so a
59 /// licence block above one at the end of a header documents the file.
60 /// It should not itself bind [`forward`](Self::forward). Where the
61 /// stretch does end at a target, [`kept`](Self::kept) alone decides.
62 /// `None` for a tool with no such block, which lets a stretch that
63 /// ends at no target return before it is even collected.
64 pub gathers: Option<fn(&Node, &[u8]) -> bool>,
65}
66
67impl DocBinding {
68 /// A binding that keeps `kept`, skips no sibling, binds every doc
69 /// comment forward, and has no doc comment gather the others.
70 #[must_use]
71 pub const fn new(kept: KeptDocs) -> Self {
72 Self {
73 kept,
74 skips: |_, _| false,
75 forward: |_, _, _| true,
76 gathers: None,
77 }
78 }
79}
80
81/// Which doc comments of a stretch a [`DocBinding`] attaches.
82#[derive(Clone, Copy, Debug)]
83pub enum KeptDocs {
84 /// Every doc comment of the stretch: rustc gives an item all the outer
85 /// doc comments above it, across blank rows, plain comments and other
86 /// attributes, which rustdoc concatenates.
87 All,
88 /// The last doc comment before the declaration's first token, and the
89 /// doc comments before it that `joins(earlier, later, code)` makes one
90 /// comment with it: javac's `Token::docComment` and Kotlin's
91 /// `PrecedingDocCommentsBinder` keep the last, and javac's
92 /// dangling-doc-comments lint names the rest. A skipped node is that
93 /// first token, a Kotlin annotation the grammar leaves outside its
94 /// declaration; when no doc comment precedes it, the first one after
95 /// it is read, as `findDocComment` reads Kotlin's modifier list.
96 Last {
97 /// Whether two adjacent doc comments are one to the tool.
98 joins: fn(&Node, &Node, &[u8]) -> bool,
99 },
100 /// The stretch's last comment, if it is a doc comment: Groovy's
101 /// `GroovydocManager` reads the comment nearest the declaration and
102 /// drops it unless it opens `/**`, so a plain comment in between
103 /// detaches it. A comment for which `hidden(comment, next)` holds,
104 /// `next` being the sibling after it, is invisible to the tool and
105 /// read past.
106 Nearest {
107 /// Whether the tool cannot see a comment.
108 hidden: fn(&Node, &Node) -> bool,
109 },
110 /// What a reverse scan over the declaration's leading trivia keeps,
111 /// from its first token back: past the comments and skipped nodes
112 /// `hidden(node, code)` names to the nearest other comment, kept if it
113 /// is a doc comment, then on over the doc comments directly before it
114 /// that `joins(earlier, later, code)` makes one with it. A member
115 /// directly before them that is hidden or plain ends the run. A skipped
116 /// node `hidden` does not name is the first token — a decorator the
117 /// grammar leaves outside its declaration — so a doc comment after it
118 /// documents nothing. Roslyn's `GetDocumentationCommentTriviaFromSyntaxNode`
119 /// reads past plain comments and directives and keeps adjacent doc
120 /// comments; JSDoc's `getLeadingJsdocComment` reads past line comments
121 /// only; tsc reads past every plain comment.
122 Leading {
123 /// Whether the scan reads past a member to the one before it.
124 hidden: fn(&Node, &[u8]) -> bool,
125 /// Whether two adjacent doc comments are one to the tool.
126 joins: fn(&Node, &Node, &[u8]) -> bool,
127 /// Whether the tool reads a member on the row where the code
128 /// before the stretch ends, asked of the target. tsc's
129 /// `getLeadingCommentRanges` starts at the line break after the
130 /// token before the declaration, so `x; /** d */` documents
131 /// nothing after it, except before a parameter, whose trailing
132 /// comment ranges it reads too; Roslyn and JSDoc read it.
133 after_code: fn(&Node) -> bool,
134 },
135}
136
137/// A [`KeptDocs::Last`] join that joins nothing.
138#[must_use]
139pub(crate) fn never_joins(_: &Node, _: &Node, _: &[u8]) -> bool {
140 false
141}
142
143/// The bytes of `node`'s own span, or nothing when the span is outside
144/// `code` — a recovery token can point past the source. Used for comment
145/// markers and for the identifiers the positional rules read.
146#[inline]
147#[must_use]
148pub(crate) fn node_bytes<'a>(node: &Node, code: &'a [u8]) -> &'a [u8] {
149 code.get(node.start_byte()..node.end_byte()).unwrap_or(&[])
150}
151
152/// `/**` opened by a byte that is neither `*` nor `/`: the Javadoc /
153/// JSDoc / Doxygen block form. Rejects the empty `/**/` and the banner
154/// `/***`, as JSDoc ("more than 3 stars will be ignored"), Doxygen
155/// (`JAVADOC_BANNER` defaults to `NO`) and Roslyn do.
156#[inline]
157#[must_use]
158pub(crate) fn javadoc_block(s: &[u8]) -> bool {
159 matches!(s, [b'/', b'*', b'*', b, ..] if *b != b'*' && *b != b'/')
160}
161
162/// A JSDoc comment as tsc reads it (`isJSDocLikeText`): `/**` followed
163/// by any byte but `/`. Unlike JSDoc itself it takes the banner `/***`;
164/// only the empty `/**/` is rejected. TypeScript and TSX follow tsc, since
165/// tsc is what reads their doc comments (#1597).
166#[inline]
167#[must_use]
168pub(crate) fn tsdoc_block(s: &[u8]) -> bool {
169 matches!(s, [b'/', b'*', b'*', b, ..] if *b != b'/')
170}
171
172/// `///` not followed by a fourth slash: the C# / Doxygen / Rust line
173/// form, including the after-member `///<`. Rejects `////`.
174#[inline]
175#[must_use]
176fn triple_slash(s: &[u8]) -> bool {
177 matches!(s, [b'/', b'/', b'/', rest @ ..] if rest.first() != Some(&b'/'))
178}
179
180/// A Doxygen comment block in a C-family language: the Javadoc block,
181/// the Qt `/*!` block, and the `///` / `//!` line forms, each also in
182/// its after-member `<` spelling (Doxygen, "Comment blocks for C-like
183/// languages"). `////` is rejected — stricter than Doxygen's own lexer,
184/// see the module doc.
185#[inline]
186#[must_use]
187pub(crate) fn doxygen(s: &[u8]) -> bool {
188 javadoc_block(s) || s.starts_with(b"/*!") || triple_slash(s) || s.starts_with(b"//!")
189}
190
191/// A Javadoc comment: any `/**` block but the empty `/**/` (a third
192/// `*` is just a leading asterisk the spec discards), plus the JEP 467
193/// Markdown form `///`. Javac's lexer also takes `/**/` and `////` as
194/// doc comments; both are rejected here, as the module doc says.
195#[inline]
196#[must_use]
197pub(crate) fn java_doc(s: &[u8]) -> bool {
198 kotlin_doc(s) || triple_slash(s)
199}
200
201/// A KDoc block as Kotlin's lexer tokenises it: `/**` except the empty
202/// `/**/`. `/***` is KDoc; line comments never are.
203#[inline]
204#[must_use]
205pub(crate) fn kotlin_doc(s: &[u8]) -> bool {
206 s.starts_with(b"/**") && s != b"/**/"
207}
208
209/// A PHPDoc block as the Zend scanner tokenises `T_DOC_COMMENT`: `/**`
210/// followed by whitespace. `/**/`, `/***` and `/**x` are plain comments.
211#[inline]
212#[must_use]
213pub(crate) fn phpdoc(s: &[u8]) -> bool {
214 s.starts_with(b"/**") && s.get(3).is_some_and(u8::is_ascii_whitespace)
215}
216
217/// A C# XML documentation comment as Roslyn lexes it: `///` (not
218/// `////`) or `/**` (not `/**/` nor `/***`). `//!` and `/*!` are the
219/// Doxygen spellings and are plain comments here.
220#[inline]
221#[must_use]
222pub(crate) fn csharp_doc(s: &[u8]) -> bool {
223 triple_slash(s) || javadoc_block(s)
224}
225
226/// A doc comment's line with the delimiters JSDoc's `unwrap` strips: the
227/// indent, one leading `*` and the space after it.
228fn jsdoc_line(line: &[u8]) -> &[u8] {
229 let line = line.trim_ascii_start();
230 let line = line.strip_prefix(b"*").unwrap_or(line);
231 line.strip_prefix(b" ").unwrap_or(line)
232}
233
234/// The lines of a `/** … */` body, delimiters and the extra stars at
235/// either end stripped (`unwrap`), each with its leading ` * ` stripped
236/// ([`jsdoc_line`]). `Clone`, so a tag's text — every line up to the next
237/// tag, as JSDoc's `toTags` reads it — can be read in place from the line
238/// that opened it.
239fn jsdoc_lines(s: &[u8]) -> impl Iterator<Item = &[u8]> + Clone {
240 let body = s.strip_prefix(b"/**").unwrap_or(s);
241 let body = body.strip_suffix(b"*/").unwrap_or(body);
242 let start = body.iter().position(|&b| b != b'*').unwrap_or(body.len());
243 let end = body
244 .iter()
245 .rposition(|&b| b != b'*')
246 .map_or(start, |at| at + 1);
247 body[start..end].split(|&b| b == b'\n').map(jsdoc_line)
248}
249
250/// The `(title, text)` a line opens when it starts a tag.
251fn jsdoc_tag_open(line: &[u8]) -> Option<(&[u8], &[u8])> {
252 let tag = line.trim_ascii_start().strip_prefix(b"@")?;
253 let title = tag.iter().take_while(|b| !b.is_ascii_whitespace()).count();
254 (title > 0).then(|| tag.split_at(title))
255}
256
257/// Whether a JSDoc tag's text names a symbol: anything left once the
258/// `{type}` that may lead it is set aside.
259fn jsdoc_names<'a>(lines: impl IntoIterator<Item = &'a [u8]>) -> bool {
260 // The lines joined, read once without joining them: a `{type}` is
261 // skipped by brace depth, and anything non-blank after it names.
262 let mut bytes = lines
263 .into_iter()
264 .flat_map(|line| line.iter().copied())
265 .skip_while(u8::is_ascii_whitespace)
266 .peekable();
267 if bytes.peek() == Some(&b'{') {
268 let mut depth = 0_usize;
269 for b in bytes.by_ref() {
270 match b {
271 b'{' => depth += 1,
272 b'}' => depth -= 1,
273 _ => {}
274 }
275 if depth == 0 {
276 break;
277 }
278 }
279 }
280 bytes.any(|b| !b.is_ascii_whitespace())
281}
282
283/// A JSDoc *virtual* comment, which stands alone: it documents the symbol
284/// its tags name rather than the code after it, and the code after it gets
285/// no doc from it (`createSymbolDoclet` drops a comment that set a name).
286/// `@file`, `@fileoverview`, `@overview` and `@module` always set one; the
287/// tags that take a name set one only when given it. `@license` sets none,
288/// so a licence comment documents what follows like any other (#1613).
289/// Titles match in any case, as the tag dictionary's `normalize` does.
290#[must_use]
291pub(crate) fn jsdoc_virtual(s: &[u8]) -> bool {
292 const ALWAYS: [&[u8]; 4] = [b"file", b"fileoverview", b"overview", b"module"];
293 const NAMING: [&[u8]; 18] = [
294 b"name",
295 b"function",
296 b"func",
297 b"method",
298 b"class",
299 b"constructor",
300 b"constant",
301 b"const",
302 b"member",
303 b"var",
304 b"namespace",
305 b"mixin",
306 b"interface",
307 b"event",
308 b"external",
309 b"host",
310 b"typedef",
311 b"callback",
312 ];
313 let is = |title: &[u8], names: &[&[u8]]| names.iter().any(|n| title.eq_ignore_ascii_case(n));
314 let mut lines = jsdoc_lines(s);
315 while let Some(line) = lines.next() {
316 let Some((title, text)) = jsdoc_tag_open(line) else {
317 continue;
318 };
319 if is(title, &ALWAYS) {
320 return true;
321 }
322 // The tag's text runs over every line up to the next tag.
323 let rest = lines.clone().take_while(|l| jsdoc_tag_open(l).is_none());
324 if is(title, &NAMING) && jsdoc_names(std::iter::once(text).chain(rest)) {
325 return true;
326 }
327 }
328 false
329}
330
331/// A TypeDoc module comment: one carrying `@module` or
332/// `@packageDocumentation`, which `getCommentImpl` withholds from any
333/// declaration and reads as the file's documentation (#1613).
334#[must_use]
335pub(crate) fn tsdoc_module(s: &[u8]) -> bool {
336 jsdoc_lines(s)
337 .filter_map(jsdoc_tag_open)
338 .any(|(title, _)| title == b"module" || title == b"packageDocumentation")
339}
340
341/// Whether `comment` lies outside every function and closure body, by the
342/// same fold [`documents_where_it_sits`] runs, for a language whose
343/// in-body doc comments document wherever they sit and so must not bind.
344#[must_use]
345pub(crate) fn outside_function_bodies<'a, T: Checker>(
346 comment: &Node<'a>,
347 ancestors: Ancestors<'a, '_>,
348) -> bool {
349 !held_in_body::<T>(comment, 0, ancestors)
350}
351
352/// Whether an ancestor of `node`, past the `skip` nearest ones, holds it
353/// in a function body. One step closure for the shared
354/// [`FoldLane::DocInBody`] memo: `fold_down` serves a cached answer to
355/// every caller on the lane, so the question must be asked the same way
356/// everywhere.
357fn held_in_body<'a, T: Checker>(
358 node: &Node<'a>,
359 skip: usize,
360 ancestors: Ancestors<'a, '_>,
361) -> bool {
362 ancestors.fold_down(
363 node,
364 skip,
365 FoldLane::DocInBody,
366 0,
367 |held, ancestor, outer, below| {
368 u8::from(held != 0 || T::holds_doc_comment_in_body(ancestor, outer, below))
369 },
370 ) != 0
371}
372
373// ---------------------------------------------------------------------
374// Documentation by position: Go, Ruby and Lua have no doc marker the
375// grammar or a tool reads per comment. go/doc's lead comment is "a
376// comment group that starts and ends in a line without any other
377// tokens and that is followed by a non-comment token on the line
378// immediately after" (go/parser); YARD attaches the comment lines
379// directly above a `def`, `class` or `module`; LDoc opens a block on
380// `---` and continues it over the `--` rows that follow until a blank
381// line. All three are properties of a *run* of comment siblings, not of
382// one node, so they are decided once per run, at its last member.
383// ---------------------------------------------------------------------
384
385/// The place half of the documentation test, for a comment already
386/// spelled or placed as documentation: it documents unless an ancestor
387/// holds it in a function or closure body
388/// ([`Checker::holds_doc_comment_in_body`]) without the comment opening
389/// that body as its documentation ([`Checker::doc_comment_opens_body`],
390/// asked of the two nearest ancestors only) and the language's tool
391/// ignores in-body doc comments
392/// ([`Checker::DOC_COMMENTS_IN_FUNCTION_BODIES`]). Shared by the marker
393/// languages' comment arms and by Lua's LDoc opener (#1597).
394#[must_use]
395pub fn documents_where_it_sits<'a, T: Checker>(
396 node: &Node<'a>,
397 code: &[u8],
398 ancestors: Ancestors<'a, '_>,
399) -> bool {
400 if T::DOC_COMMENTS_IN_FUNCTION_BODIES {
401 return true;
402 }
403 // The two nearest ancestors may excuse the comment itself
404 // (`Checker::doc_comment_opens_body`: Rust's inner doc heading a
405 // body); every ancestor above them answers by the path alone, folded
406 // once per walk rather than climbed once per comment.
407 let mut below = *node;
408 for (ancestor, outer) in ancestors.iter(node).take(2) {
409 if T::holds_doc_comment_in_body(&ancestor, outer, &below)
410 && !T::doc_comment_opens_body(node, &ancestor, &below, code)
411 {
412 return false;
413 }
414 below = ancestor;
415 }
416 !held_in_body::<T>(node, 2, ancestors)
417}
418
419/// Whether a doc comment documents without a following declaration,
420/// before [`documents_where_it_sits`] has its say: every doc comment of a
421/// language with no [`Checker::DOC_BINDING`], and a bound language's that
422/// does not attach forward ([`DocBinding::forward`]). A forward one
423/// documents only through [`bound_doc_run`] (#1613).
424#[must_use]
425pub fn documents_unbound<'a, T: Checker>(
426 comment: &Node<'a>,
427 code: &[u8],
428 ancestors: Ancestors<'a, '_>,
429) -> bool {
430 T::DOC_BINDING.is_none_or(|binding| !(binding.forward)(comment, code, ancestors))
431}
432
433/// Credits the doc comments a bound language's tool attaches to the
434/// declaration after them (#1613). Called from the LOC comment arm for
435/// every comment; `credit` receives each attached doc comment, whose own
436/// arm has already billed its rows as `cloc`. A language with no
437/// [`Checker::DOC_BINDING`] credits nothing here.
438///
439/// The comments compared are one *stretch*: the comment siblings between
440/// two other siblings, read past those [`DocBinding::skips`] names and any
441/// parse error that is no doc target and holds none. Only the stretch's last
442/// comment does more than one sibling step, so a stretch of `k` members
443/// costs `O(k)` steps in total, the #1446 bound: it finds the sibling the
444/// stretch ends at and, when that is an [`Checker::is_doc_target`], walks
445/// back once to credit what the binding keeps. A stretch that ends
446/// anywhere else — a statement, a macro call, a closing brace, the end of
447/// the file — documents nothing. Members are siblings, so one in-body test
448/// ([`documents_where_it_sits`]) answers for them all.
449pub fn bound_doc_run<'a, T: Checker>(
450 comment: &Node<'a>,
451 code: &[u8],
452 ancestors: Ancestors<'a, '_>,
453 mut credit: impl FnMut(&Node<'a>),
454) {
455 let Some(binding) = T::DOC_BINDING else {
456 return;
457 };
458 let end = match stretch_end::<T>(comment, code, ancestors, binding.skips) {
459 // A later member decides.
460 StretchEnd::Undecided => return,
461 // Nothing documents, and no block gathers the stretch either.
462 StretchEnd::Elsewhere if binding.gathers.is_none() => return,
463 end => end,
464 };
465 if !documents_where_it_sits::<T>(comment, code, ancestors) {
466 return;
467 }
468 let (stretch, code_before) = collect_stretch::<T>(comment, code, ancestors, binding.skips);
469 let forward =
470 |n: &Node<'a>| T::is_doc_comment(n, code) && (binding.forward)(n, code, ancestors);
471 let StretchEnd::AtTarget { next, target } = end else {
472 if let Some(gathers) = binding.gathers {
473 bind_gathered::<T>(&stretch, code, gathers, forward)
474 .iter()
475 .for_each(&mut credit);
476 }
477 return;
478 };
479 let kept = match binding.kept {
480 KeptDocs::All => stretch.iter().copied().filter(forward).collect(),
481 KeptDocs::Last { joins } => bind_last::<T>(&stretch, code, joins, forward),
482 KeptDocs::Nearest { hidden } => bind_nearest::<T>(&stretch, next, hidden, forward)
483 .into_iter()
484 .collect(),
485 KeptDocs::Leading {
486 hidden,
487 joins,
488 after_code,
489 } => {
490 // A member on the row the code before the stretch ends on is
491 // after that code, where a scan from the next line break does
492 // not reach.
493 let after_code_row = code_before
494 .filter(|_| !after_code(&target))
495 .map(|before| before.end_row());
496 let attaches = |n: &Node<'a>| forward(n) && Some(n.start_row()) != after_code_row;
497 bind_leading::<T>(&stretch, code, hidden, joins, attaches)
498 }
499 };
500 kept.iter().for_each(&mut credit);
501}
502
503/// Where a stretch of comments ends, asked of its last comment.
504enum StretchEnd<'a> {
505 /// A later comment of the stretch decides.
506 Undecided,
507 /// At `target`, an [`Checker::is_doc_target`] or a parse error holding
508 /// one. `next` is the sibling right after the asking comment, where a
509 /// walk back from the target begins.
510 AtTarget { next: Node<'a>, target: Node<'a> },
511 /// At a statement, a closing brace, the end of the file — anything
512 /// that documents nothing.
513 Elsewhere,
514}
515
516/// Where the stretch `comment` belongs to ends. The scan stops at the
517/// first sibling that is neither skipped nor a parse error read past, so
518/// it never passes a later comment of the stretch. Only an error is asked
519/// whether it is read past, and only once the binding's own `skips` and
520/// [`Checker::is_doc_target`] have passed on it, so a binding that decides
521/// errors itself (Doxygen's) keeps its reading.
522fn stretch_end<'a, T: Checker>(
523 comment: &Node<'a>,
524 code: &[u8],
525 ancestors: Ancestors<'a, '_>,
526 skips: for<'b> fn(&Node<'a>, Ancestors<'a, 'b>) -> bool,
527) -> StretchEnd<'a> {
528 let mut after = ancestors.siblings_after(comment).peekable();
529 let Some(&next) = after.peek() else {
530 return StretchEnd::Elsewhere;
531 };
532 match after.find(|n| !skips(n, ancestors) && !read_past_error::<T>(n, code, ancestors)) {
533 Some(target) if T::is_comment(&target) => StretchEnd::Undecided,
534 Some(target)
535 if T::is_doc_target(&target, code, ancestors)
536 || (target.is_error() && error_holds_target::<T>(&target, code, ancestors)) =>
537 {
538 StretchEnd::AtTarget { next, target }
539 }
540 _ => StretchEnd::Elsewhere,
541 }
542}
543
544/// The stretch `comment` ends, in source order, and the sibling before it:
545/// `comment` and the siblings before it that are comments or skipped,
546/// read past the parse errors [`stretch_end`] reads past with the same
547/// test, so every member whose scan stopped at the next comment is in
548/// the stretch that decides it. The errors themselves are left out: the
549/// `safe` of a `safe fn` is between the item's doc comments and its
550/// first token, not that token. An error the scan does not read past —
551/// one holding a target — ends the stretch and is the sibling before it.
552fn collect_stretch<'a, T: Checker>(
553 comment: &Node<'a>,
554 code: &[u8],
555 ancestors: Ancestors<'a, '_>,
556 skips: for<'b> fn(&Node<'a>, Ancestors<'a, 'b>) -> bool,
557) -> (Vec<Node<'a>>, Option<Node<'a>>) {
558 let mut stretch = vec![*comment];
559 let mut before = None;
560 // An error read past is no member of the stretch: neither a comment
561 // nor the first token a binder stops at. Left in, a binder that keeps
562 // the last doc comment before the first token (tsc's, JSDoc's,
563 // javac's) would read the error as that token and keep the comment
564 // above it instead of the one the tool attaches.
565 for sibling in ancestors
566 .siblings_before(comment)
567 .filter(|sibling| !read_past_error::<T>(sibling, code, ancestors))
568 {
569 if T::is_comment(&sibling) || skips(&sibling, ancestors) {
570 stretch.push(sibling);
571 } else {
572 before = Some(sibling);
573 break;
574 }
575 }
576 stretch.reverse();
577 (stretch, before)
578}
579
580/// Whether a stretch reads past `node`: a parse error that is no doc
581/// target and holds none — tokens the grammar could not place, such as the
582/// `safe` of a Rust 2024 foreign item, which tree-sitter-rust leaves as an
583/// error between the item's doc comment and the rest of it. Given the
584/// benefit of the doubt, as Doxygen's binding gives one (`doxygen_skips`),
585/// rather than dropping the doc comment on a parse failure.
586fn read_past_error<'a, T: Checker>(
587 node: &Node<'a>,
588 code: &[u8],
589 ancestors: Ancestors<'a, '_>,
590) -> bool {
591 node.is_error()
592 && !T::is_doc_target(node, code, ancestors)
593 && !error_holds_target::<T>(node, code, ancestors)
594}
595
596/// Whether a child of the parse error `error` is a doc target: a
597/// declaration parsed amid code the grammar could not place, which the
598/// error then stands in for as Doxygen's `doxygen_target` reads one.
599/// `ancestors` is the error's chain. Errors are rare, so the copy of the
600/// chain its children are judged with costs nothing on the common path.
601/// Without a chain the children are judged with none, which a target
602/// test that climbs pays for at `O(depth)`; every production walk lends
603/// one, so only a test reaches that fallback.
604fn error_holds_target<'a, T: Checker>(
605 error: &Node<'a>,
606 code: &[u8],
607 ancestors: Ancestors<'a, '_>,
608) -> bool {
609 let chain = ancestors.children_chain(error);
610 let inside = chain
611 .as_deref()
612 .map_or_else(Ancestors::unknown, Ancestors::known);
613 error
614 .children()
615 .any(|child| child.is_named() && T::is_doc_target(&child, code, inside))
616}
617
618/// What a stretch that ends at no target documents: what its last
619/// gathering comment ([`DocBinding::gathers`]) takes — the forward doc
620/// comments before it, and the line doc comments on the rows directly
621/// below it, which a line-comment tool reads as one block with it. The
622/// gathering comment does not bind forward, so `forward` drops it; it
623/// documents where it sits.
624fn bind_gathered<'a, T: Checker>(
625 stretch: &[Node<'a>],
626 code: &[u8],
627 gathers: fn(&Node, &[u8]) -> bool,
628 forward: impl Fn(&Node<'a>) -> bool,
629) -> Vec<Node<'a>> {
630 let Some(gatherer) = stretch
631 .iter()
632 .rposition(|n| T::is_doc_comment(n, code) && gathers(n, code))
633 else {
634 return Vec::new();
635 };
636 let line = |n: &Node| node_bytes(n, code).starts_with(b"//");
637 // Only a line-comment gatherer reads the line doc comments below it
638 // as one block with itself.
639 let gathers_below = line(&stretch[gatherer]);
640 let mut last = gatherer;
641 while let Some(below) = stretch.get(last + 1)
642 && gathers_below
643 && line(below)
644 && T::is_doc_comment(below, code)
645 && below.start_row() == stretch[last].end_row().saturating_add(1)
646 {
647 last += 1;
648 }
649 stretch[..=last].iter().copied().filter(forward).collect()
650}
651
652/// [`KeptDocs::Last`] over a stretch in source order: the last doc
653/// comment before the first skipped node — the declaration's first token —
654/// with the doc comments it joins, or else the first doc comment after it.
655fn bind_last<'a, T: Checker>(
656 stretch: &[Node<'a>],
657 code: &[u8],
658 joins: fn(&Node, &Node, &[u8]) -> bool,
659 forward: impl Fn(&Node<'a>) -> bool,
660) -> Vec<Node<'a>> {
661 let split = stretch
662 .iter()
663 .position(|n| !T::is_comment(n))
664 .unwrap_or(stretch.len());
665 let (before, after) = stretch.split_at(split);
666 let Some(last) = before.iter().rposition(&forward) else {
667 return after.iter().copied().find(forward).into_iter().collect();
668 };
669 joined_run(before, last, code, joins, forward)
670}
671
672/// [`KeptDocs::Leading`] over a stretch in source order: the members
673/// before the first token, read back past the hidden ones to the nearest
674/// other one, kept if it is a doc comment that binds forward, with the doc
675/// comments directly before it that it joins.
676fn bind_leading<'a, T: Checker>(
677 stretch: &[Node<'a>],
678 code: &[u8],
679 hidden: fn(&Node, &[u8]) -> bool,
680 joins: fn(&Node, &Node, &[u8]) -> bool,
681 forward: impl Fn(&Node<'a>) -> bool,
682) -> Vec<Node<'a>> {
683 let first_token = stretch
684 .iter()
685 .position(|n| !T::is_comment(n) && !hidden(n, code))
686 .unwrap_or(stretch.len());
687 let before = &stretch[..first_token];
688 let Some(last) = before.iter().rposition(|n| !hidden(n, code)) else {
689 return Vec::new();
690 };
691 if !forward(&before[last]) {
692 return Vec::new();
693 }
694 joined_run(before, last, code, joins, forward)
695}
696
697/// `before[last]` with the doc comments directly before it that it
698/// joins, in source order. `joins` is asked first: for a binding whose
699/// `joins` is [`never_joins`] it answers at once, where `forward`
700/// would scan the comment's bytes for nothing.
701fn joined_run<'a>(
702 before: &[Node<'a>],
703 last: usize,
704 code: &[u8],
705 joins: fn(&Node, &Node, &[u8]) -> bool,
706 forward: impl Fn(&Node<'a>) -> bool,
707) -> Vec<Node<'a>> {
708 let mut first = last;
709 while first > 0
710 && joins(&before[first - 1], &before[first], code)
711 && forward(&before[first - 1])
712 {
713 first -= 1;
714 }
715 before[first..=last].to_vec()
716}
717
718/// [`KeptDocs::Nearest`] over a stretch in source order whose last
719/// member `next` follows: the comment nearest the declaration that the
720/// tool can see, if it is a doc comment.
721fn bind_nearest<'a, T: Checker>(
722 stretch: &[Node<'a>],
723 next: Node<'a>,
724 hidden: fn(&Node, &Node) -> bool,
725 forward: impl Fn(&Node<'a>) -> bool,
726) -> Option<Node<'a>> {
727 let mut later = next;
728 for member in stretch.iter().rev() {
729 if T::is_comment(member) && !hidden(member, &later) {
730 return Some(*member).filter(forward);
731 }
732 later = *member;
733 }
734 None
735}
736
737/// Credits the rows of the comment run that `comment` ends, when the
738/// run is documentation: it sits directly above a node the language's
739/// [`Checker::is_doc_target`] accepts, or its first member opens a
740/// block per [`Checker::is_doc_run_opener`]. Called from a language's
741/// LOC comment arm for every comment; only the run's last member does
742/// any work beyond one sibling step, so a run of `k` rows costs `O(k)`
743/// sibling steps in total (#1597).
744///
745/// A run is the maximal chain of comment siblings on consecutive rows
746/// that hold nothing but comments; a trailing comment (`x := 1 // t`)
747/// is never a member and never seeds one, which the previous-sibling
748/// test decides — the `ploc` row set cannot, because code that opened a
749/// child space (`func A() {} // t`) lives in the child's stats. A blank
750/// row ends the run, as does anything that is not a comment; a comment
751/// the language says closes one ([`Checker::ends_doc_run`]) is its last
752/// member. The decision needs the row after the run, so it is made at
753/// the last member, which then walks back to credit the whole run through
754/// `credit(first_row, last_row)` per member, skipping the members
755/// [`Checker::is_doc_run_directive`] names (`//go:generate`, a Ruby
756/// shebang) — part of the group, not of the documentation.
757pub fn leading_doc_run<'a, T: Checker>(
758 comment: &Node<'a>,
759 code: &[u8],
760 ancestors: Ancestors<'a, '_>,
761 mut credit: impl FnMut(usize, usize),
762) {
763 let next = ancestors.siblings_after(comment).next();
764 if next
765 .as_ref()
766 .is_some_and(|n| run_continues_to::<T>(comment, n, code))
767 {
768 // A later member decides.
769 return;
770 }
771 let Some(first) = run_first_member::<T>(comment, code, ancestors) else {
772 return;
773 };
774 // `next` is `comment`'s sibling, so `ancestors` is its chain too.
775 let below = next.filter(|n| T::doc_target_start_row(n) == comment.end_row().saturating_add(1));
776 let documents = below.is_some_and(|n| T::is_doc_target(&n, code, ancestors))
777 || T::is_doc_run_opener(&first, below.as_ref(), code, ancestors);
778 if !documents {
779 return;
780 }
781 for member in std::iter::once(first).chain(ancestors.siblings_after(&first)) {
782 if member.start_byte() > comment.start_byte() {
783 break;
784 }
785 if !T::is_doc_run_directive(&member, code) {
786 credit(member.start_row(), member.end_row());
787 }
788 }
789}
790
791/// Whether `next`, the sibling after `comment`, is a later member of
792/// the same run: a comment on `comment`'s own row, or on the next row
793/// when `comment` does not end the run ([`Checker::ends_doc_run`]).
794/// Only `next` is tested as a comment, so `comment` must already be
795/// one: asked about a code node, it reports that the comment on the row
796/// below continues the code's run, which once let a backward scan run
797/// to the top of the file for every LDoc block (#1597).
798pub(crate) fn run_continues_to<T: Checker>(comment: &Node, next: &Node, code: &[u8]) -> bool {
799 T::is_comment(next)
800 && (next.start_row() <= comment.end_row()
801 || (next.start_row() == comment.end_row().saturating_add(1)
802 && !T::ends_doc_run(comment, code)))
803}
804
805/// The first member of the run `comment` ends: the comment siblings
806/// before it on consecutive rows that hold nothing but comments, so two
807/// comments sharing a row (`/* a */ /* b */`) are one row of the run.
808/// `None` when code precedes `comment` on its own row: a trailing
809/// comment belongs to no run, and a row where code precedes its
810/// comments ends the run below it. One backward pass over one sibling
811/// iterator.
812fn run_first_member<'a, T: Checker>(
813 comment: &Node<'a>,
814 code: &[u8],
815 ancestors: Ancestors<'a, '_>,
816) -> Option<Node<'a>> {
817 // `row_first` is the earliest comment found on the row being scanned;
818 // `first` is the first member of the earliest row known to be clean.
819 // Each step moves `row_first` to the sibling just before it, so one
820 // iterator walks the whole run.
821 let mut before = ancestors.siblings_before(comment);
822 let mut first = None;
823 let mut row_first = *comment;
824 loop {
825 match before.next() {
826 // Another token on this row: a comment extends the row, and
827 // code before it makes every comment on the row a trailing one.
828 Some(prev) if prev.end_row() >= row_first.start_row() => {
829 if !T::is_comment(&prev) {
830 return first;
831 }
832 row_first = prev;
833 }
834 // A comment ending on the row above: this row is clean, and
835 // the run continues upward unless that comment ends a run.
836 Some(prev)
837 if T::is_comment(&prev)
838 && prev.end_row().saturating_add(1) == row_first.start_row()
839 && !T::ends_doc_run(&prev, code) =>
840 {
841 first = Some(row_first);
842 row_first = prev;
843 }
844 _ => return Some(row_first),
845 }
846 }
847}
848
849/// A go/doc directive line, part of a lead comment group but not of
850/// its text (go.dev/doc/comment, "Directives"): `//go:build`,
851/// `//line `, `//extern `, `//export `, and the general
852/// `//[a-z0-9]+:[a-z0-9]` form. A directive has no space after `//`.
853#[must_use]
854pub(crate) fn go_directive(s: &[u8]) -> bool {
855 let Some(rest) = s.strip_prefix(b"//") else {
856 return false;
857 };
858 if rest.starts_with(b"line ") || rest.starts_with(b"extern ") || rest.starts_with(b"export ") {
859 return true;
860 }
861 let word = rest
862 .iter()
863 .take_while(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
864 .count();
865 word > 0
866 && rest.get(word) == Some(&b':')
867 && rest
868 .get(word.saturating_add(1))
869 .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
870}
871
872/// A Ruby comment that opens a documentation block whatever follows it:
873/// RDoc's `##`, which introduces a metaprogrammed method's docs
874/// (`RDoc::Parser::Ruby`), or a YARD `@!` directive (`# @!method`,
875/// `# @!attribute`, `# @!macro`, `# @!parse`), which documents a DSL call
876/// rather than a `def` (#1597). The `##` is exactly two `#`: YARD sets its
877/// hash flag only for a `##` prefix and RDoc reads a lone `##` line, so a
878/// `####…` banner or a `##### Helpers #####` divider opens nothing.
879#[must_use]
880pub(crate) fn ruby_directive_opener(s: &[u8]) -> bool {
881 if s.starts_with(b"##") && s.get(2) != Some(&b'#') {
882 return true;
883 }
884 s.strip_prefix(b"#").is_some_and(|rest| {
885 rest.iter()
886 .position(|b| *b != b' ' && *b != b'\t')
887 .is_some_and(|at| rest[at..].starts_with(b"@!"))
888 })
889}
890
891/// The comment lines YARD drops before documenting: a shebang, an
892/// encoding line and the `frozen_string_literal` magic comment, as
893/// YARD's `SHEBANG_LINE`, `ENCODING_LINE` and `FROZEN_STRING_LINE` name
894/// them, case-insensitively. YARD applies them only before the first
895/// token; here a line is excluded wherever it sits, so the match is on
896/// the whole comment — `# coding: utf-8`, `# -*- coding: utf-8 -*-`,
897/// `# vim: set fileencoding=utf-8 :`, `# frozen_string_literal: true` —
898/// and a documentation row that only mentions an encoding (`# Encoding:
899/// defaults to UTF-8.`) stays documentation. What this loses is a
900/// comment that repeats a magic line mid-file.
901#[must_use]
902pub(crate) fn ruby_magic_comment(s: &[u8]) -> bool {
903 if s.starts_with(b"#!") {
904 return true;
905 }
906 s.strip_prefix(b"#")
907 .map(<[u8]>::trim_ascii)
908 .is_some_and(|body| ruby_modeline(body) || ruby_magic_setting(body))
909}
910
911fn contains_ci(hay: &[u8], needle: &[u8]) -> bool {
912 hay.windows(needle.len())
913 .any(|window| window.eq_ignore_ascii_case(needle))
914}
915
916/// An Emacs (`-*- … -*-`) or Vim (`vim: …`) modeline that carries the
917/// magic setting among others: an encoding, or for Emacs the
918/// frozen-string flag. A modeline's key is never a magic one, so
919/// `ruby_magic_setting` cannot also match it.
920fn ruby_modeline(body: &[u8]) -> bool {
921 if body.len() >= 6 && body.starts_with(b"-*-") && body.ends_with(b"-*-") {
922 return contains_ci(body, b"coding") || contains_ci(body, b"frozen");
923 }
924 body.get(..4)
925 .is_some_and(|head| head.eq_ignore_ascii_case(b"vim:"))
926 && contains_ci(body, b"coding")
927}
928
929/// A comment that is one magic setting and nothing else: `key: value`
930/// or `key=value`, the key a magic one and the value a single word.
931fn ruby_magic_setting(body: &[u8]) -> bool {
932 let Some(separator) = body.iter().position(|b| matches!(b, b':' | b'=')) else {
933 return false;
934 };
935 let (key, value) = body.split_at(separator);
936 let value = value.get(1..).unwrap_or_default().trim_ascii_start();
937 [
938 &b"coding"[..],
939 b"encoding",
940 b"frozen_string_literal",
941 b"frozen-string-literal",
942 ]
943 .iter()
944 .any(|known| key.trim_ascii_end().eq_ignore_ascii_case(known))
945 && !value.is_empty()
946 && value
947 .iter()
948 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
949}
950
951/// A POD block that is nothing but `comment` regions — `=begin comment`
952/// … `=end comment` or a `=for comment` paragraph — which perlpod leaves
953/// to no formatter and perlfaq7 recommends for commenting code out: POD
954/// syntax, not documentation. A region covers only its own text, as
955/// perlpod scopes it: a `=for comment` paragraph ends at the next blank
956/// line and a `=begin comment` region at its `=end comment`, so a block
957/// that goes on to document something (`=head1 NAME` …) is documentation.
958#[must_use]
959pub(crate) fn pod_comment_region(s: &[u8]) -> bool {
960 enum Region {
961 Outside,
962 Paragraph,
963 Begin,
964 }
965 let mut region = Region::Outside;
966 let mut regions = 0_usize;
967 for line in s.split(|&byte| byte == b'\n') {
968 let blank = line.trim_ascii().is_empty();
969 match region {
970 Region::Paragraph if blank => region = Region::Outside,
971 Region::Begin if pod_command(line, b"=end").is_some_and(is_comment_format) => {
972 region = Region::Outside;
973 }
974 Region::Paragraph | Region::Begin => {}
975 Region::Outside if blank => {}
976 Region::Outside => {
977 if pod_command(line, b"=for").is_some_and(is_comment_format) {
978 region = Region::Paragraph;
979 } else if pod_command(line, b"=begin").is_some_and(is_comment_format) {
980 region = Region::Begin;
981 } else {
982 // `=cut` closes the block; any other line documents, or
983 // opens a block that never was a comment region.
984 return regions > 0 && line.starts_with(b"=cut");
985 }
986 regions += 1;
987 }
988 }
989 }
990 regions > 0
991}
992
993/// The text after a POD command line's `=<command>` and the whitespace
994/// separating it from its argument, or `None` when `line` is not that
995/// command.
996fn pod_command<'s>(line: &'s [u8], command: &[u8]) -> Option<&'s [u8]> {
997 let rest = line.strip_prefix(command)?;
998 rest.first()
999 .is_some_and(u8::is_ascii_whitespace)
1000 .then(|| rest.trim_ascii_start())
1001}
1002
1003/// Whether a POD command's format name is `comment`.
1004fn is_comment_format(format: &[u8]) -> bool {
1005 format
1006 .strip_prefix(b"comment")
1007 .is_some_and(|after| after.first().is_none_or(u8::is_ascii_whitespace))
1008}
1009
1010/// An LDoc block opener, per `lang.lua`: a line comment starting with
1011/// three or more dashes (`start_comment_ = '^%-%-%-+'`, so a line of
1012/// dashes alone opens too) unless it ends in a dash run preceded by a
1013/// non-dash (`end_comment_ = '[^%-]%-%-+[^-]*\n$'` — `--- x ---` is an
1014/// end marker), or a long comment whose body opens with a dash
1015/// (`block_comment = '^%-%-%[=*%[%-+'`).
1016#[must_use]
1017pub(crate) fn ldoc_opener(s: &[u8]) -> bool {
1018 if let Some(body) = lua_long_comment_body(s) {
1019 return body.first() == Some(&b'-');
1020 }
1021 s.starts_with(b"---") && !ldoc_end_marker(s)
1022}
1023
1024/// A Lua long comment, `--[[ … ]]` or a levelled `--[==[ … ]==]`, as
1025/// opposed to a line comment that merely starts `--[`.
1026#[must_use]
1027pub(crate) fn lua_long_comment(s: &[u8]) -> bool {
1028 lua_long_comment_body(s).is_some()
1029}
1030
1031/// The text after a long comment's opening bracket, or `None` when `s`
1032/// is not a long comment.
1033fn lua_long_comment_body(s: &[u8]) -> Option<&[u8]> {
1034 let rest = s.strip_prefix(b"--[")?;
1035 let levels = rest.iter().take_while(|b| **b == b'=').count();
1036 rest.get(levels..)?.strip_prefix(b"[")
1037}
1038
1039/// `[^-]--+[^-]*$`: the line's final dash run has two or more dashes and
1040/// something other than a dash before it.
1041fn ldoc_end_marker(s: &[u8]) -> bool {
1042 let trimmed = s.len() - s.iter().rev().take_while(|b| **b != b'-').count();
1043 let dashes = s[..trimmed]
1044 .iter()
1045 .rev()
1046 .take_while(|b| **b == b'-')
1047 .count();
1048 dashes >= 2 && trimmed > dashes
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054
1055 /// Every language's `DOC_BINDING` spreads `..DocBinding::new(kept)`
1056 /// and relies on its defaults: nothing skipped, every doc comment
1057 /// binding forward, no gathering comment. Each const is evaluated at
1058 /// compile time, so this is the one runtime call that pins them.
1059 #[cfg(feature = "rust")]
1060 #[test]
1061 fn doc_binding_defaults_skip_nothing_and_bind_everything_forward() {
1062 use crate::langs::RustCode;
1063 use crate::node::Tree;
1064 let source = b"/// d\nfn f() {}\n";
1065 let tree = Tree::new::<RustCode>(source);
1066 let comment = tree
1067 .get_root()
1068 .preorder()
1069 .find(|node| RustCode::is_comment(node))
1070 .expect("the fixture has a comment");
1071 let binding = DocBinding::new(KeptDocs::All);
1072 assert!(matches!(binding.kept, KeptDocs::All));
1073 assert!(!(binding.skips)(&comment, Ancestors::unknown()));
1074 assert!((binding.forward)(&comment, source, Ancestors::unknown()));
1075 assert!(binding.gathers.is_none());
1076 }
1077
1078 #[test]
1079 fn ruby_directive_opener_accepts_rdoc_and_yard_forms() {
1080 assert!(ruby_directive_opener(b"##"));
1081 assert!(ruby_directive_opener(b"## Section"));
1082 assert!(ruby_directive_opener(b"# @!method foo"));
1083 assert!(ruby_directive_opener(b"#\t@!macro bar"));
1084 assert!(!ruby_directive_opener(b"# plain"));
1085 assert!(!ruby_directive_opener(b"# @param x"));
1086 assert!(!ruby_directive_opener(b"#!/usr/bin/env ruby"));
1087 // A banner or divider: three or more `#` are neither RDoc's lone
1088 // `##` nor YARD's `##` prefix.
1089 assert!(!ruby_directive_opener(b"###"));
1090 assert!(!ruby_directive_opener(b"########################"));
1091 assert!(!ruby_directive_opener(b"##### Private helpers #####"));
1092 }
1093
1094 /// The walk back from a stretch's last comment reads past the parse
1095 /// errors the scan forward from each member read past, so a member
1096 /// whose scan stopped at the next comment is decided by the stretch
1097 /// holding it. Not valid Rust — no qualifier may precede a doc comment
1098 /// — but an error between two doc comments needs one, and a binding
1099 /// that keeps every doc comment is where an undecided member shows. The
1100 /// test pins that the two scans agree, not a reading of Rust.
1101 #[cfg(feature = "rust")]
1102 #[test]
1103 fn both_scans_read_past_the_same_error() {
1104 use crate::langs::RustCode;
1105 use crate::node::Tree;
1106 let source = b"unsafe extern \"C\" {\n /// a\n safe\n /// b\n fn g();\n}\n";
1107 let tree = Tree::new::<RustCode>(source);
1108 let comments: Vec<_> = tree
1109 .get_root()
1110 .preorder()
1111 .filter(|node| RustCode::is_comment(node))
1112 .collect();
1113 assert_eq!(comments.len(), 2);
1114 assert!(
1115 tree.get_root().preorder().any(|node| node.is_error()),
1116 "the fixture no longer parses `safe` as an error"
1117 );
1118 let mut credited = Vec::new();
1119 for comment in &comments {
1120 bound_doc_run::<RustCode>(comment, source, Ancestors::unknown(), |doc| {
1121 credited.push(doc.start_row());
1122 });
1123 }
1124 assert_eq!(credited, [1, 3]);
1125 }
1126
1127 #[test]
1128 fn javadoc_block_accepts_the_block_form_only() {
1129 assert!(javadoc_block(b"/** doc */"));
1130 assert!(javadoc_block(b"/**\n * doc\n */"));
1131 assert!(!javadoc_block(b"/**/"), "empty block");
1132 assert!(!javadoc_block(b"/*** banner */"), "three stars");
1133 assert!(!javadoc_block(b"/* plain */"));
1134 assert!(!javadoc_block(b"// plain"));
1135 assert!(!javadoc_block(b"/**"), "nothing after the marker");
1136 }
1137
1138 #[test]
1139 fn triple_slash_rejects_a_fourth_slash() {
1140 assert!(triple_slash(b"/// doc"));
1141 assert!(triple_slash(b"///"), "bare marker still opens a doc line");
1142 assert!(triple_slash(b"///< after member"));
1143 assert!(!triple_slash(b"//// banner"));
1144 assert!(!triple_slash(b"// plain"));
1145 }
1146
1147 #[test]
1148 fn doxygen_covers_the_four_markers_and_their_member_forms() {
1149 for doc in [
1150 &b"/** d */"[..],
1151 b"/*! d */",
1152 b"/// d",
1153 b"//! d",
1154 b"/**< d */",
1155 b"/*!< d */",
1156 b"///< d",
1157 b"//!< d",
1158 ] {
1159 assert!(doxygen(doc), "{}", String::from_utf8_lossy(doc));
1160 }
1161 for plain in [&b"/* d */"[..], b"// d", b"/**/", b"/*** d */", b"//// d"] {
1162 assert!(!doxygen(plain), "{}", String::from_utf8_lossy(plain));
1163 }
1164 }
1165
1166 #[test]
1167 fn java_doc_follows_javac_on_three_stars_and_jep_467() {
1168 assert!(java_doc(b"/** d */"));
1169 assert!(java_doc(b"/*** d */"), "javac discards leading asterisks");
1170 assert!(java_doc(b"/// d"), "JEP 467 Markdown doc comment");
1171 assert!(!java_doc(b"/**/"));
1172 assert!(!java_doc(b"//// d"));
1173 assert!(!java_doc(b"/* d */"));
1174 assert!(!java_doc(b"// d"));
1175 }
1176
1177 #[test]
1178 fn kotlin_doc_is_any_block_but_the_empty_one() {
1179 assert!(kotlin_doc(b"/** d */"));
1180 assert!(kotlin_doc(b"/*** d */"));
1181 assert!(!kotlin_doc(b"/**/"));
1182 assert!(!kotlin_doc(b"/// d"), "KDoc has no line form");
1183 }
1184
1185 #[test]
1186 fn phpdoc_needs_whitespace_after_the_marker() {
1187 assert!(phpdoc(b"/** d */"));
1188 assert!(phpdoc(b"/**\n * d\n */"));
1189 assert!(!phpdoc(b"/**/"));
1190 assert!(!phpdoc(b"/*** d */"));
1191 assert!(!phpdoc(b"/**d */"));
1192 assert!(!phpdoc(b"# d"));
1193 assert!(!phpdoc(b"// d"));
1194 }
1195
1196 #[test]
1197 fn csharp_doc_rejects_the_doxygen_only_spellings() {
1198 assert!(csharp_doc(b"/// <summary>d</summary>"));
1199 assert!(csharp_doc(b"/** d */"));
1200 for plain in [
1201 &b"//! d"[..],
1202 b"/*! d */",
1203 b"//// d",
1204 b"/**/",
1205 b"/*** d */",
1206 b"// d",
1207 ] {
1208 assert!(!csharp_doc(plain), "{}", String::from_utf8_lossy(plain));
1209 }
1210 }
1211
1212 /// Each row is what `jsdoc -X` 4.x made of the comment above a call:
1213 /// a doclet of its own (virtual) or nothing. A name may sit on the
1214 /// line after its tag, and a tag inline in the description is text.
1215 #[test]
1216 fn jsdoc_virtual_follows_jsdoc_naming_tags() {
1217 crate::test_support::assert_classifies(
1218 jsdoc_virtual,
1219 &[
1220 b"/** @file */",
1221 b"/**\n * @fileoverview x\n */",
1222 b"/** @overview */",
1223 b"/** @module */",
1224 b"/**\n * @typedef {Object} T\n */",
1225 b"/**\n * @typedef {Object}\n * T\n */",
1226 b"/**\n * @function f\n */",
1227 b"/**\n * @callback cb\n */",
1228 b"/** @FILE */",
1229 b"/** @FileOverview */",
1230 b"/** @Typedef {Object} T */",
1231 b"/** @function f **/",
1232 ],
1233 &[
1234 b"/**\n * @license MIT\n */",
1235 b"/**\n * @typedef {Object}\n */",
1236 b"/**\n * @function\n */",
1237 b"/** @function **/",
1238 // A nested `{type}` is one type: no name follows it.
1239 b"/**\n * @typedef {{a: number}}\n */",
1240 b"/** x @file */",
1241 b"/** d */",
1242 ],
1243 );
1244 assert!(tsdoc_module(b"/**\n * @packageDocumentation\n */"));
1245 assert!(tsdoc_module(b"/**\n * @module m\n */"));
1246 assert!(!tsdoc_module(b"/**\n * @license MIT\n */"));
1247 }
1248
1249 #[test]
1250 fn go_directive_matches_go_ast_is_directive() {
1251 for directive in [
1252 &b"//go:build linux"[..],
1253 b"//go:generate go run x",
1254 b"//line foo.go:12",
1255 b"//extern f",
1256 b"//export F",
1257 b"//nolint:errcheck",
1258 b"//x:1",
1259 ] {
1260 assert!(
1261 go_directive(directive),
1262 "{}",
1263 String::from_utf8_lossy(directive)
1264 );
1265 }
1266 for text in [
1267 &b"// go:build linux"[..],
1268 b"//Note: not a directive",
1269 b"//x:",
1270 b"//exported F",
1271 b"// d",
1272 ] {
1273 assert!(!go_directive(text), "{}", String::from_utf8_lossy(text));
1274 }
1275 }
1276
1277 /// A block is a comment only while nothing but `comment` regions fill
1278 /// it: POD after a region's end (`=head1 NAME` past a `=for comment`
1279 /// paragraph or an `=end comment`) is documentation pod2text renders.
1280 #[test]
1281 fn pod_comment_region_is_the_comment_format_only() {
1282 for region in [
1283 &b"=begin comment\n\nold();\n\n=end comment\n\n=cut"[..],
1284 b"=for comment\nold();\n\n=cut",
1285 b"=begin comment\n=end comment\n=cut",
1286 b"=for comment\nold();\n",
1287 b"=begin comment\nold();\n",
1288 b"=for comment a\n\n=for comment b\n\n=cut",
1289 b"=begin comment\nx\n=end comment\n=for comment y\n\n=cut",
1290 b"=for comment\r\nold();\r\n\r\n=cut\r\n",
1291 ] {
1292 assert!(
1293 pod_comment_region(region),
1294 "{}",
1295 String::from_utf8_lossy(region)
1296 );
1297 }
1298 for doc in [
1299 &b"=pod\n\nText.\n\n=cut"[..],
1300 b"=head1 NAME\n\nFoo\n\n=cut",
1301 b"=begin html\n\n<p>x</p>\n\n=end html\n\n=cut",
1302 b"=begin comments\n=end comments\n=cut",
1303 b"=beginning",
1304 b"=for comment\nInternal note.\n\n=head1 NAME\n\nFoo - bar\n\n=cut",
1305 b"=begin comment\n\nold();\n\n=end comment\n\n=head1 X\n\nDoc.\n\n=cut",
1306 b"=for comment\nnote\n\nPlain text paragraph.\n\n=cut",
1307 ] {
1308 assert!(!pod_comment_region(doc), "{}", String::from_utf8_lossy(doc));
1309 }
1310 }
1311
1312 /// The match is on the whole comment, because it is applied anywhere
1313 /// in the file: a documentation row that mentions an encoding, or
1314 /// names `frozen_string_literal` in prose, is not a magic line.
1315 #[test]
1316 fn ruby_magic_comment_matches_whole_magic_lines_only() {
1317 for magic in [
1318 &b"#!/usr/bin/env ruby"[..],
1319 b"# coding: utf-8",
1320 b"# encoding: UTF-8",
1321 b"# Encoding: binary",
1322 b"#coding=utf-8",
1323 b"# -*- coding: utf-8 -*-",
1324 b"# -*- mode: ruby; encoding: utf-8 -*-",
1325 b"# -*- frozen_string_literal: true -*-",
1326 b"# vim: set fileencoding=utf-8 :",
1327 b"# frozen_string_literal: true",
1328 b"# frozen-string-literal: false",
1329 ] {
1330 assert!(
1331 ruby_magic_comment(magic),
1332 "{}",
1333 String::from_utf8_lossy(magic)
1334 );
1335 }
1336 for doc in [
1337 &b"# Encoding: defaults to UTF-8."[..],
1338 b"# Handles transcoding: see below",
1339 b"# Pass `frozen_string_literal: true` to opt in.",
1340 b"# Sets the encoding= attribute",
1341 b"# d",
1342 b"#",
1343 ] {
1344 assert!(!ruby_magic_comment(doc), "{}", String::from_utf8_lossy(doc));
1345 }
1346 }
1347}