Skip to main content

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}