Skip to main content

miniextendr_macros/
roxygen.rs

1//! Roxygen tag extraction and processing for R wrapper generation.
2//!
3//! This module extracts roxygen2-style tags (e.g., `@param`, `@examples`) from Rust
4//! doc comments and propagates them to generated R wrapper code.
5//!
6//! # Usage
7//!
8//! In Rust doc comments, use roxygen2 tags:
9//!
10//! ```rust,ignore
11//! /// @param x A numeric input.
12//! /// @return The squared value.
13//! /// @examples
14//! /// square(4)
15//! #[miniextendr]
16//! pub fn square(x: f64) -> f64 { x * x }
17//! ```
18//!
19//! # How doc lines carry over
20//!
21//! Every reader here ([`explicit_roxygen_tags_from_attrs`],
22//! [`leading_prose_from_attrs`], [`strip_roxygen_from_attrs`]) walks the same
23//! lines ([`doc_lines`]) with the same roles ([`classify`]):
24//!
25//! - Text before the first `@tag` line is leading prose. It becomes
26//!   `@description` (when the block has none) with its lines, blank lines and
27//!   indentation kept, and it stays in rustdoc. Its `[...]` links lose their
28//!   brackets unless the crate sets `roxygen_prose_links = "keep"`
29//!   ([`ProseLinks`]). Under `"keep"`, and in the text of every explicit tag
30//!   but the code ones ([`CODE_TAGS`]), only a link roxygen2 can never
31//!   resolve loses them ([`is_rustdoc_only_target`]). An inline link whose
32//!   destination is such a Rust path (`[text](crate::x)`) keeps only its text
33//!   under either setting ([`inline_rustdoc_destination`]); other
34//!   `[text](url)` links stay. A block that renders no help page (its own
35//!   `@noRd`, or a caller's [`RdPage::Suppressed`]: `noexport`, a class
36//!   without a page) loses every link in its prose and its non-code tag
37//!   text, whatever the setting.
38//! - A tag runs from its `@tag` line to the next one. A multi-line tag
39//!   (`@description`, `@return`, `@examples`, ...) keeps its blank lines (a
40//!   roxygen2 paragraph break; a blank line in an example) and the indentation
41//!   of its continuation lines, less the one space rustdoc's `/// ` puts there.
42//!   Trailing blank lines are dropped. roxygen2 markdown reads a continuation
43//!   indented 4 or more spaces after a blank line as a code block, as rustdoc
44//!   does.
45//! - A joined tag (`@title`, `@keywords`, `@concept`, `@aliases`) folds its
46//!   wrapped lines onto one line.
47//! - A single-line tag (`@export`, `@noRd`, `@rdname topic`, ...) ends at its
48//!   line. Lines after it, up to the next tag, are rustdoc only.
49//! - Rustdoc keeps the leading prose and the rustdoc-only lines, and drops
50//!   every tag with its text.
51//!
52//! # R Package Configuration
53//!
54//! For roxygen2 to process multiline tags correctly, add this to your `DESCRIPTION` file:
55//!
56//! ```text
57//! Roxygen: list(markdown = TRUE)
58//! ```
59
60use std::collections::HashSet;
61
62/// Tags that allow multi-line content (continuation lines appended).
63/// All other tags are treated as single-line.
64const MULTILINE_TAGS: &[&str] = &[
65    "examples",
66    "description",
67    "details",
68    "return",
69    "returns",
70    "param",
71    "note",
72    "seealso",
73    "section",
74    "format",
75    "references",
76    "slot",
77    "field",
78    "value", // synonym for return
79    "prop",  // S7 property documentation (roxygen2 8.0.0+)
80    // Tags whose *name* is a single word but whose text may wrap onto the
81    // next `///` line. Dropping the wrapped part silently truncated them (#1476).
82    "describeIn",
83    "family",
84    "inherit",
85    "inheritParams",
86    "inheritSection",
87    // roxygen2's other tags whose text runs over several lines (code, raw Rd,
88    // NAMESPACE directives, free text).
89    "examplesIf",
90    "usage",
91    "rawRd",
92    "rawNamespace",
93    "evalRd",
94    "evalNamespace",
95    "source",
96    "author",
97];
98
99/// Tags whose text is R code or raw Rd, not markdown: their `[...]` is never
100/// a link, so [`neutralize_tag_links`] leaves them as written.
101const CODE_TAGS: &[&str] = &[
102    "examples",
103    "examplesIf",
104    "usage",
105    "eval",
106    "evalRd",
107    "evalNamespace",
108    "rawRd",
109    "rawNamespace",
110];
111
112/// Tags whose wrapped continuation lines are joined back onto one line with a
113/// space instead of a newline: roxygen2 wants these on a single line (or reads
114/// them as one whitespace-separated list), but a long title, keyword list,
115/// concept, or alias list in a Rust doc comment can be wrapped by the author.
116const JOINED_TAGS: &[&str] = &["title", "keywords", "concept", "aliases"];
117
118/// A bare `@name` / `@rdname` (topic written on the next `///` line, which
119/// roxygen2 accepts) takes exactly one continuation line as its topic. Once
120/// the tag has a topic it is single-line again, so following prose is not
121/// absorbed (see `test_rdname_stays_single_line`). The wrapper registry
122/// decides page routing from the single `#' @name <topic>` line, so a dropped
123/// topic would silently change which page a function lands on.
124fn is_bare_topic_tag(tag: &str) -> bool {
125    #[allow(non_exhaustive_omitted_patterns)] match tag {
    "@name" | "@rdname" => true,
    _ => false,
}matches!(tag, "@name" | "@rdname")
126}
127
128/// Check if a tag name supports multi-line content.
129fn is_multiline_tag(tag: &str) -> bool {
130    // Extract the tag name from "@tagname ..." or "@tagname"
131    let tag_name = tag
132        .strip_prefix('@')
133        .and_then(|rest| rest.split_whitespace().next())
134        .unwrap_or("");
135    MULTILINE_TAGS.contains(&tag_name)
136}
137
138/// Check if a tag's continuation lines are joined with a space (see [`JOINED_TAGS`]).
139fn is_joined_tag(tag: &str) -> bool {
140    let tag_name = tag
141        .strip_prefix('@')
142        .and_then(|rest| rest.split_whitespace().next())
143        .unwrap_or("");
144    JOINED_TAGS.contains(&tag_name)
145}
146
147// region: doc lines
148
149/// One line of a string-literal doc attribute.
150#[derive(#[automatically_derived]
impl ::core::fmt::Debug for DocLine {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field2_finish(f, "DocLine",
            "attr", &self.attr, "text", &&self.text)
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for DocLine { }
#[automatically_derived]
impl ::core::cmp::PartialEq for DocLine {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.attr == other.attr && self.text == other.text
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for DocLine {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<usize>;
        let _: ::core::cmp::AssertParamIsEq<String>;
    }
}Eq)]
151struct DocLine {
152    /// Index of the doc attribute in the attribute slice it was read from.
153    attr: usize,
154    /// The line with its rustdoc lead removed (see [`doc_lines`]); empty for
155    /// a blank line.
156    text: String,
157}
158
159/// The lines of every string-literal doc attribute in `attrs`, in order.
160///
161/// Non-doc attributes are skipped, so an interleaved `#[cfg(...)]` never
162/// interrupts a tag (#613). A `#[doc = include_str!(..)]` is not a literal and
163/// stays unread. Per attribute:
164///
165/// - a single-line literal (each `///` line, a `#[doc = "..."]` from
166///   `macro_rules!`) loses at most the one leading space rustdoc's `/// `
167///   puts there;
168/// - a multi-line literal (`/** ... */`, `#[doc = "a\nb"]`) loses the common
169///   leading whitespace of its non-blank lines, so a relative indent survives;
170/// - a whitespace-only line becomes an empty one.
171fn doc_lines(attrs: &[syn::Attribute]) -> Vec<DocLine> {
172    let mut out = Vec::new();
173    for (attr, a) in attrs.iter().enumerate() {
174        if !a.path().is_ident("doc") {
175            continue;
176        }
177        let syn::Meta::NameValue(nv) = &a.meta else {
178            continue;
179        };
180        let syn::Expr::Lit(expr_lit) = &nv.value else {
181            continue;
182        };
183        let syn::Lit::Str(lit) = &expr_lit.lit else {
184            continue;
185        };
186        let value = lit.value();
187        // `"".lines()` yields nothing, but an empty `///` is one blank line.
188        let lines: Vec<&str> = if value.is_empty() {
189            ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [""]))vec![""]
190        } else {
191            value.lines().collect()
192        };
193        let lead = if value.contains('\n') {
194            common_indent(&lines)
195        } else {
196            " "
197        };
198        for line in lines {
199            let text = if line.trim().is_empty() {
200                ""
201            } else {
202                line.strip_prefix(lead).unwrap_or(line)
203            };
204            out.push(DocLine {
205                attr,
206                text: text.to_string(),
207            });
208        }
209    }
210    out
211}
212
213/// The leading whitespace every non-blank line of `lines` starts with.
214fn common_indent<'a>(lines: &[&'a str]) -> &'a str {
215    lines
216        .iter()
217        .filter(|line| !line.trim().is_empty())
218        .map(|line| &line[..line.len() - line.trim_start().len()])
219        .reduce(|common, lead| {
220            let shared = common
221                .char_indices()
222                .zip(lead.chars())
223                .take_while(|((_, a), b)| a == b)
224                .last()
225                .map_or(0, |((i, c), _)| i + c.len_utf8());
226            &common[..shared]
227        })
228        .unwrap_or("")
229}
230
231/// What a doc line is to roxygen extraction and rustdoc stripping.
232#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for LineRole { }
#[automatically_derived]
impl ::core::clone::Clone for LineRole {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for LineRole { }Copy, #[automatically_derived]
impl ::core::fmt::Debug for LineRole {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                LineRole::Prose => "Prose",
                LineRole::TagStart => "TagStart",
                LineRole::TagBody => "TagBody",
                LineRole::Rustdoc => "Rustdoc",
            })
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for LineRole { }
#[automatically_derived]
impl ::core::cmp::PartialEq for LineRole {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for LineRole { }Eq)]
233enum LineRole {
234    /// Before the first tag: leading prose (promoted to `@description`, kept
235    /// in rustdoc).
236    Prose,
237    /// A line starting with `@` (after leading whitespace): a new tag.
238    TagStart,
239    /// Part of the open tag's text, or a blank line that ends a tag (trailing,
240    /// or before the next tag).
241    TagBody,
242    /// After a single-line tag: rustdoc only, never R. A blank line is
243    /// rustdoc when a rustdoc-only line follows it.
244    Rustdoc,
245}
246
247/// How the tag that is open takes the lines that follow it.
248#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for OpenTag { }
#[automatically_derived]
impl ::core::clone::Clone for OpenTag {
    #[inline]
    fn clone(&self) -> Self {
        let _: ::core::clone::AssertParamIsClone<bool>;
        *self
    }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for OpenTag { }Copy)]
249enum OpenTag {
250    /// [`MULTILINE_TAGS`]: every line up to the next tag, blank lines included.
251    Multi,
252    /// [`JOINED_TAGS`], or a bare `@name` / `@rdname` still waiting for its
253    /// topic ([`is_bare_topic_tag`]): the non-blank lines, folded onto one
254    /// line. A bare topic tag takes one line, then it is `Single`.
255    Joined { bare_topic: bool },
256    /// Any other tag: its own line only.
257    Single,
258}
259
260impl OpenTag {
261    fn of(tag_line: &str) -> Self {
262        if is_multiline_tag(tag_line) {
263            Self::Multi
264        } else if is_joined_tag(tag_line) {
265            Self::Joined { bare_topic: false }
266        } else if is_bare_topic_tag(tag_line) {
267            Self::Joined { bare_topic: true }
268        } else {
269            Self::Single
270        }
271    }
272}
273
274/// The role of each of `lines` ([`doc_lines`]): the one rule the tag
275/// extractor, the leading-prose reader and the rustdoc strip share.
276fn classify(lines: &[DocLine]) -> Vec<LineRole> {
277    let mut roles = Vec::with_capacity(lines.len());
278    let mut open: Option<OpenTag> = None;
279    for line in lines {
280        let text = line.text.trim_start();
281        let role = if text.starts_with('@') {
282            open = Some(OpenTag::of(text));
283            LineRole::TagStart
284        } else {
285            match open {
286                None => LineRole::Prose,
287                Some(OpenTag::Multi | OpenTag::Joined { bare_topic: false }) => LineRole::TagBody,
288                Some(OpenTag::Joined { bare_topic: true }) => {
289                    if !text.is_empty() {
290                        open = Some(OpenTag::Single);
291                    }
292                    LineRole::TagBody
293                }
294                Some(OpenTag::Single) => LineRole::Rustdoc,
295            }
296        };
297        roles.push(role);
298    }
299    // A blank line after a single-line tag goes with the paragraph after it:
300    // rustdoc before a rustdoc-only line, the tag's end otherwise.
301    let mut next_is_rustdoc = false;
302    for (line, role) in lines.iter().zip(roles.iter_mut()).rev() {
303        if !line.text.is_empty() {
304            next_is_rustdoc = *role == LineRole::Rustdoc;
305        } else if *role == LineRole::Rustdoc && !next_is_rustdoc {
306            *role = LineRole::TagBody;
307        }
308    }
309    roles
310}
311
312// endregion
313
314/// Whether a doc block's roxygen text can reach a rendered help page, as far
315/// as the caller knows, which decides what happens to its `[...]` links.
316///
317/// A block that renders no page sends roxygen2 no link: none of them could
318/// render, and roxygen2 still resolves (and warns on) every one it is handed
319/// (#1818). The reader sees an explicit `@noRd` in the block itself; the
320/// caller says when its attributes take the page away.
321#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for RdPage { }
#[automatically_derived]
impl ::core::clone::Clone for RdPage {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for RdPage { }Copy, #[automatically_derived]
impl ::core::fmt::Debug for RdPage {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                RdPage::Rendered => "Rendered",
                RdPage::Suppressed => "Suppressed",
            })
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for RdPage { }
#[automatically_derived]
impl ::core::cmp::PartialEq for RdPage {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for RdPage { }Eq)]
322pub(crate) enum RdPage {
323    /// The block renders a page unless its own tags say `@noRd`: its links
324    /// follow the crate's [`ProseLinks`] setting.
325    Rendered,
326    /// The block renders no page whatever its tags: a `noexport` entry point,
327    /// a class without a page, or a method of one. Every link loses its
328    /// brackets ([`Neutralize::AllLinks`]).
329    Suppressed,
330}
331
332impl RdPage {
333    /// [`RdPage::Suppressed`] when `no_page`, else [`RdPage::Rendered`].
334    pub(crate) fn suppressed_if(no_page: bool) -> Self {
335        if no_page {
336            Self::Suppressed
337        } else {
338            Self::Rendered
339        }
340    }
341}
342
343/// Extract roxygen tag lines (starting with '@') from Rust doc attributes.
344///
345/// Most tags capture only a single line. Multi-line tags like `@examples`,
346/// `@description`, `@param`, and `@return` append their continuation lines,
347/// blank lines and indentation included (see the module docs).
348///
349/// Leading prose (the paragraphs before the first tag) is promoted to
350/// `@description` when the block has none. `page` says whether the block can
351/// render a help page ([`RdPage`]).
352pub(crate) fn roxygen_tags_from_attrs(attrs: &[syn::Attribute], page: RdPage) -> Vec<String> {
353    roxygen_tags_from_attrs_impl(attrs, page)
354}
355
356/// Extract roxygen tags for an impl-block method.
357///
358/// Identical to [`roxygen_tags_from_attrs`] — leading prose is promoted to
359/// `@description` in every context. Kept as a named alias so the class-system
360/// generators read clearly at the call site.
361pub(crate) fn roxygen_tags_from_attrs_for_r6_method(
362    attrs: &[syn::Attribute],
363    page: RdPage,
364) -> Vec<String> {
365    roxygen_tags_from_attrs_impl(attrs, page)
366}
367
368/// Whether the doc comment in `attrs` has the roxygen tag `tag` (matched as
369/// [`has_roxygen_tag`] does), for a caller deciding a block's [`RdPage`]
370/// before it reads the block's tags.
371pub(crate) fn attrs_have_roxygen_tag(attrs: &[syn::Attribute], tag: &str) -> bool {
372    has_roxygen_tag(&explicit_roxygen_tags_from_attrs(attrs), tag)
373}
374
375/// Core implementation of roxygen tag extraction from `#[doc = "..."]` attributes.
376///
377/// Walks the doc lines ([`doc_lines`]) with their roles ([`classify`]). Lines
378/// starting with `@` begin a new tag; continuation lines are appended only if
379/// the current tag is multiline-capable (or joined, see [`JOINED_TAGS`]).
380///
381/// Only doc attributes are read, in order, so interleaved `#[cfg(...)]` or
382/// other non-doc attributes never break multiline-tag continuation (#613).
383/// This is a pure parse-side transform: the emitted `TokenStream` is
384/// unaffected.
385///
386/// Leading prose (paragraphs before the first `@tag`) is promoted to a
387/// `@description` tag — never `@title`. The `@title` is left to the caller
388/// (structural name); see [`leading_prose_from_attrs`] for why.
389fn roxygen_tags_from_attrs_impl(attrs: &[syn::Attribute], page: RdPage) -> Vec<String> {
390    roxygen_tags_with(attrs, crate::crate_config::roxygen_prose_links(), page)
391}
392
393/// [`roxygen_tags_from_attrs_impl`] with the crate's [`ProseLinks`] setting
394/// passed in (testable without a manifest).
395///
396/// Every explicit tag loses its rustdoc-only links ([`neutralize_tag_links`])
397/// whatever the setting, which governs the leading prose only. A block that
398/// renders no page ([`RdPage::Suppressed`], or its own `@noRd`) loses every
399/// link, in the prose and in each non-code tag (#1818).
400fn roxygen_tags_with(attrs: &[syn::Attribute], links: ProseLinks, page: RdPage) -> Vec<String> {
401    let explicit = explicit_roxygen_tags_from_attrs(attrs);
402    let no_page = page == RdPage::Suppressed || has_roxygen_tag(&explicit, "noRd");
403    let (links, tag_links) = if no_page {
404        (ProseLinks::Strip, Neutralize::AllLinks)
405    } else {
406        (links, Neutralize::RustdocOnly)
407    };
408    let mut tags: Vec<String> = explicit
409        .into_iter()
410        .map(|tag| neutralize_tag_links(tag, tag_links))
411        .collect();
412
413    // Check which tags are present
414    let tag_names_set = tag_names(&tags);
415    let has_description = tag_names_set.contains("description");
416
417    // Promote leading prose doc comments to `@description` (all paragraphs before
418    // the first `@tag`), unless the author already wrote an explicit `@description`.
419    //
420    // The page `@title` is NOT synthesized from prose. Rustdoc summaries are markdown
421    // written for `cargo doc` — intra-doc links (`[`Foo`]`, `[x][crate::y]`) and code
422    // spans — which roxygen2's markdown parser tries to resolve as R `\link{}` topics
423    // and fails ("could not resolve link to topic" / "refers to un-installed package").
424    // Titles come from the structural name instead: the wrapper name for standalone
425    // functions (see `lib.rs`) and `@title {Name} Class` for class blocks (see
426    // `ClassDocBuilder`). Demoting prose to `@description` keeps it visible while the
427    // title stays link-free.
428    //
429    // `leading_prose_from_attrs` returns `None` for tag-led blocks (no leading prose),
430    // so `@inherit`/`@rdname`-only docs never gain a spurious description.
431    if !has_description && let Some(desc) = leading_prose_from_attrs(attrs, links) {
432        tags.insert(0, ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@description {0}", desc))
    })format!("@description {}", desc));
433    }
434
435    tags
436}
437
438/// Parse only the author-written `@tag` lines from doc attributes — no
439/// leading-prose promotion.
440///
441/// A tag line is left-trimmed, so `///   @param` still starts a tag. A
442/// multi-line tag keeps its continuation lines as written (less the rustdoc
443/// lead, [`doc_lines`]) and its interior blank lines, and drops its trailing
444/// ones; a joined or bare-topic tag folds its lines onto one with a space.
445///
446/// [`roxygen_tags_from_attrs_impl`] layers the leading-prose → `@description`
447/// promotion on top of this. [`doc_conflict_warnings`] must use this raw parse
448/// instead: comparing the *synthesized* description (all leading prose) against
449/// the implicit one (second paragraph) warned on every multi-paragraph doc
450/// comment that had no explicit `@description` at all (#1172).
451fn explicit_roxygen_tags_from_attrs(attrs: &[syn::Attribute]) -> Vec<String> {
452    let lines = doc_lines(attrs);
453    let roles = classify(&lines);
454    let mut tags: Vec<String> = Vec::new();
455    // Blank lines seen inside a multi-line tag, written out only when more of
456    // its text follows: a tag's trailing blank lines are dropped.
457    let mut pending_blanks = 0;
458
459    for (line, role) in lines.iter().zip(roles) {
460        match role {
461            LineRole::TagStart => {
462                pending_blanks = 0;
463                tags.push(line.text.trim_start().to_string());
464            }
465            LineRole::TagBody => {
466                let Some(last) = tags.last_mut() else {
467                    continue;
468                };
469                if is_multiline_tag(last) {
470                    if line.text.is_empty() {
471                        pending_blanks += 1;
472                    } else {
473                        for _ in 0..std::mem::take(&mut pending_blanks) {
474                            last.push('\n');
475                        }
476                        last.push('\n');
477                        last.push_str(&line.text);
478                    }
479                } else if !line.text.is_empty() {
480                    // Wrapped joined tag or bare topic: fold onto one line.
481                    last.push(' ');
482                    last.push_str(line.text.trim());
483                }
484            }
485            // Leading prose is read by `leading_prose_from_attrs` and promoted
486            // to @description in `roxygen_tags_from_attrs_impl`; rustdoc-only
487            // lines never reach R.
488            LineRole::Prose | LineRole::Rustdoc => {}
489        }
490    }
491
492    tags
493}
494
495/// One roxygen comment line: `#' text`, or a bare `#'` for a blank `text`
496/// (no trailing whitespace in the generated R). Render author tag text
497/// through this (or the `push_roxygen_tags*` helpers), never
498/// `format!("#' {}", ..)`.
499pub(crate) fn roxygen_line(text: &str) -> String {
500    if text.trim().is_empty() {
501        "#'".to_string()
502    } else {
503        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' {0}", text))
    })format!("#' {text}")
504    }
505}
506
507/// Render roxygen tag lines as "#' ..." comment lines.
508///
509/// Multiline tags (containing '\n') are split into separate `#'` lines
510/// ([`roxygen_line`]).
511pub(crate) fn format_roxygen_tags(tags: &[String]) -> String {
512    let mut out = String::new();
513    for line in tags.iter().flat_map(|tag| tag.lines()) {
514        out.push_str(&roxygen_line(line));
515        out.push('\n');
516    }
517    out
518}
519
520/// Push roxygen tag lines into a vector of R wrapper lines.
521///
522/// Multiline tags (containing '\n') are split into separate `#'` lines
523/// ([`roxygen_line`]).
524pub(crate) fn push_roxygen_tags(lines: &mut Vec<String>, tags: &[String]) {
525    lines.extend(tags.iter().flat_map(|tag| tag.lines()).map(roxygen_line));
526}
527
528/// Like [`push_roxygen_tags`] but takes `&[&str]` for filtered tag slices.
529pub(crate) fn push_roxygen_tags_str(lines: &mut Vec<String>, tags: &[&str]) {
530    lines.extend(tags.iter().flat_map(|tag| tag.lines()).map(roxygen_line));
531}
532
533/// Return true if the tag list contains a specific roxygen tag.
534///
535/// Supports both single-word tags (e.g., `"export"`, `"noRd"`) and
536/// multi-word tags (e.g., `"keywords internal"`). For single-word tags,
537/// matches the first word after `@`. For multi-word tags, matches the
538/// full content after `@` (trimmed).
539pub(crate) fn has_roxygen_tag(tags: &[String], tag: &str) -> bool {
540    if tag.contains(' ') {
541        // Multi-word tag: match the full content after @
542        tags.iter().any(|t| {
543            t.trim_start()
544                .strip_prefix('@')
545                .is_some_and(|rest| rest.trim() == tag)
546        })
547    } else {
548        tag_names(tags).contains(tag)
549    }
550}
551
552/// Whether the author registers the function themselves, with `@export` or
553/// `@exportS3Method` (bare or `pkg::generic`), so a public standalone fn gets
554/// no automatic `@export`. Next to a delayed `@exportS3Method pkg::generic`
555/// a bare `@export` makes roxygen2 write an eager registration as well, and
556/// the package fails to load when `pkg` is only in Suggests (#1944).
557pub(crate) fn has_export_tag(tags: &[String]) -> bool {
558    let names = tag_names(tags);
559    names.contains("export") || names.contains("exportS3Method")
560}
561
562/// The topic named by the tag list's own `@rdname` tag, if it has one.
563///
564/// Class generators default every method onto the class page (`@rdname
565/// <Class>`); a method-level `/// @rdname other` splits it onto its own page.
566/// Every emission path that injects the class default must consult this first
567/// so a user-supplied topic is honoured once, not duplicated by the default.
568pub(crate) fn rdname_value(tags: &[String]) -> Option<&str> {
569    tags.iter().find_map(|t| {
570        let rest = t.trim_start().strip_prefix("@rdname")?;
571        let value = rest.trim();
572        (rest.starts_with(char::is_whitespace) && !value.is_empty()).then_some(value)
573    })
574}
575
576/// Push `#' @rdname <topic>` for a scaffolding block that follows its method:
577/// the method's page ([`method_page`]: its `@describeIn` destination or own
578/// `@rdname`), else `default` (the class page).
579pub(crate) fn push_rdname_or_default(lines: &mut Vec<String>, tags: &[String], default: &str) {
580    let topic = method_page(tags, default);
581    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @rdname {0}", topic))
    })format!("#' @rdname {topic}"));
582}
583
584/// The `@source` provenance line for a generated documentation block, or
585/// `None` unless the crate opted in with `[package.metadata.miniextendr]
586/// source_tags = true` (#1552). On a shared Rd page the tag collects one
587/// paragraph per function, so it is off by default; the `# Generated from
588/// Rust fn … (file:line:col)` comment above each wrapper stays as the
589/// navigation pointer. Every emitter goes through here (or the two
590/// specialisations below), so the knob has one implementation.
591pub(crate) fn source_tag(text: impl AsRef<str>) -> Option<String> {
592    source_tag_with(crate::crate_config::source_tags_enabled(), text.as_ref())
593}
594
595/// [`source_tag`] with the crate setting passed in (testable without a manifest).
596pub(crate) fn source_tag_with(enabled: bool, text: &str) -> Option<String> {
597    enabled.then(|| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @source {0}", text))
    })format!("#' @source {text}"))
598}
599
600/// The `@source` line for a class method: ``Generated by miniextendr from
601/// `Type::method` ``, subject to [`source_tag`]'s opt-in.
602pub(crate) fn method_source_tag(
603    type_ident: &syn::Ident,
604    method_ident: &syn::Ident,
605) -> Option<String> {
606    source_tag(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("Generated by miniextendr from `{0}::{1}`",
                type_ident, method_ident))
    })format!(
607        "Generated by miniextendr from `{}::{}`",
608        type_ident, method_ident
609    ))
610}
611
612/// The `@source` line for a class definition: ``Generated by miniextendr from
613/// Rust type `Type` ``, subject to [`source_tag`]'s opt-in.
614pub(crate) fn class_source_tag(type_ident: &syn::Ident) -> Option<String> {
615    source_tag(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("Generated by miniextendr from Rust type `{0}`",
                type_ident))
    })format!(
616        "Generated by miniextendr from Rust type `{}`",
617        type_ident
618    ))
619}
620
621/// Extract the set of tag names from a list of roxygen tag strings.
622///
623/// Each tag string is expected to start with `@tagname`. Returns a set of
624/// the tag names (without the `@` prefix).
625fn tag_names(tags: &[String]) -> HashSet<&str> {
626    let mut names = HashSet::new();
627    for tag in tags {
628        let trimmed = tag.trim_start();
629        let name = trimmed
630            .strip_prefix('@')
631            .and_then(|rest| rest.split_whitespace().next());
632        if let Some(name) = name {
633            names.insert(name);
634        }
635    }
636    names
637}
638
639/// Find the value of a specific roxygen tag (e.g., "title" for `@title ...`).
640///
641/// Returns `None` if the tag is not present or has no value.
642#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
643pub(crate) fn find_tag_value<'a>(tags: &'a [String], tag_name: &str) -> Option<&'a str> {
644    for tag in tags {
645        let trimmed = tag.trim_start();
646        if let Some(rest) = trimmed.strip_prefix('@') {
647            let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
648            if let Some(name) = parts.next()
649                && name == tag_name
650            {
651                // Get the value (everything after the tag name)
652                return parts.next().map(|s| s.trim());
653            }
654        }
655    }
656    None
657}
658
659/// Normalize text for comparison: lowercase, collapse whitespace, strip trailing punctuation.
660///
661/// Used by `doc_conflict_warnings` to compare explicit `@title`/`@description` values
662/// with implicit values derived from the doc comment structure. Normalization ensures
663/// minor formatting differences (extra spaces, trailing periods) don't trigger false warnings.
664#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
665fn normalize_for_comparison(s: &str) -> String {
666    let lower = s.to_lowercase();
667    let mut result = String::new();
668    for word in lower.split_whitespace() {
669        if !result.is_empty() {
670            result.push(' ');
671        }
672        result.push_str(word);
673    }
674    result.truncate(
675        result
676            .trim_end_matches(|c: char| c.is_ascii_punctuation())
677            .len(),
678    );
679    result
680}
681
682/// Extract the implicit title from doc attributes (first sentence, up to first `.` or newline).
683///
684/// Returns `None` if there are no doc comments or if docs start with a `@tag`.
685#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
686pub(crate) fn implicit_title_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
687    let mut lines = Vec::new();
688
689    for attr in attrs {
690        if !attr.path().is_ident("doc") {
691            continue;
692        }
693        let syn::Meta::NameValue(nv) = &attr.meta else {
694            continue;
695        };
696        let syn::Expr::Lit(expr_lit) = &nv.value else {
697            continue;
698        };
699        let syn::Lit::Str(lit) = &expr_lit.lit else {
700            continue;
701        };
702
703        let content = lit.value();
704        let trimmed = content.trim();
705
706        // If we hit a @tag before any content, there's no implicit title
707        if trimmed.starts_with('@') {
708            if lines.is_empty() {
709                return None;
710            }
711            break;
712        }
713
714        // Empty line ends first sentence for title extraction
715        if trimmed.is_empty() {
716            break;
717        }
718
719        // Check if this line contains a sentence-ending period
720        if let Some(pos) = trimmed.find(". ") {
721            lines.push(trimmed[..pos].to_string());
722            break;
723        } else if trimmed.ends_with('.') {
724            lines.push(trimmed.trim_end_matches('.').to_string());
725            break;
726        } else {
727            lines.push(trimmed.to_string());
728        }
729    }
730
731    if lines.is_empty() {
732        None
733    } else {
734        Some(lines.join(" "))
735    }
736}
737
738/// Extract the implicit description from doc attributes (second paragraph).
739///
740/// In roxygen2, the first paragraph is the title and the second paragraph is the
741/// description. This function skips the first paragraph (up to the first blank line)
742/// and returns the second paragraph.
743///
744/// Returns `None` if there is no second paragraph, no doc comments, or if docs
745/// start with a `@tag`.
746#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
747pub(crate) fn implicit_description_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
748    let mut lines = Vec::new();
749    let mut found_first_paragraph = false;
750    let mut in_gap = false;
751
752    for attr in attrs {
753        if !attr.path().is_ident("doc") {
754            continue;
755        }
756        let syn::Meta::NameValue(nv) = &attr.meta else {
757            continue;
758        };
759        let syn::Expr::Lit(expr_lit) = &nv.value else {
760            continue;
761        };
762        let syn::Lit::Str(lit) = &expr_lit.lit else {
763            continue;
764        };
765
766        let content = lit.value();
767        let trimmed = content.trim();
768
769        // If we hit a @tag, stop
770        if trimmed.starts_with('@') {
771            break;
772        }
773
774        if !found_first_paragraph {
775            // Still in the first paragraph (title)
776            if trimmed.is_empty() {
777                // Blank line — first paragraph ended, now in the gap
778                found_first_paragraph = true;
779                in_gap = true;
780            }
781            // Non-empty lines before first blank are title — skip
782        } else if in_gap {
783            // Between paragraphs — skip blank lines
784            if !trimmed.is_empty() {
785                // Start of second paragraph
786                in_gap = false;
787                lines.push(trimmed.to_string());
788            }
789        } else {
790            // In second paragraph
791            if trimmed.is_empty() {
792                // End of second paragraph
793                break;
794            }
795            lines.push(trimmed.to_string());
796        }
797    }
798
799    if lines.is_empty() {
800        None
801    } else {
802        Some(lines.join(" "))
803    }
804}
805
806/// What leading prose does with `[...]` link syntax on its way to
807/// `@description`: the crate's `roxygen_prose_links` setting in
808/// `[package.metadata.miniextendr]`.
809///
810/// rustdoc and roxygen2 read the same `[name()]` / `[pkg::name()]` / `[Topic]`
811/// / `` [`Topic`] `` syntax as a link to a Rust item and to an R help topic
812/// respectively, so the two cannot be told apart from the text: rustdoc
813/// resolves `[name()]` to an in-scope Rust fn and warns
814/// `broken_intra_doc_links` on any it cannot find. The crate says which reader
815/// its doc comments are written for.
816#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for ProseLinks { }
#[automatically_derived]
impl ::core::clone::Clone for ProseLinks {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for ProseLinks { }Copy, #[automatically_derived]
impl ::core::fmt::Debug for ProseLinks {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                ProseLinks::Strip => "Strip",
                ProseLinks::Keep => "Keep",
            })
    }
}Debug, #[automatically_derived]
impl ::core::default::Default for ProseLinks {
    #[inline]
    fn default() -> Self { Self::Strip }
}Default, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for ProseLinks { }
#[automatically_derived]
impl ::core::cmp::PartialEq for ProseLinks {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for ProseLinks { }Eq)]
817pub(crate) enum ProseLinks {
818    /// `"strip"`, the default: doc comments are written for rustdoc, so a link
819    /// loses its brackets ([`Neutralize::AllLinks`]) and roxygen2 never tries
820    /// to resolve a Rust item as an R topic.
821    #[default]
822    Strip,
823    /// `"keep"`: doc comments are written for roxygen2, so leading prose keeps
824    /// its links, as the text of an explicit tag does. Only the links roxygen2
825    /// can never resolve lose their brackets ([`Neutralize::RustdocOnly`]).
826    /// A block that renders no page is read as under `Strip` ([`RdPage`]).
827    Keep,
828}
829
830impl ProseLinks {
831    /// Parse the manifest spelling (`"strip"` / `"keep"`).
832    pub(crate) fn parse_name(name: &str) -> Option<Self> {
833        match name {
834            "strip" => Some(Self::Strip),
835            "keep" => Some(Self::Keep),
836            _ => None,
837        }
838    }
839}
840
841/// Collect the leading prose of a doc comment (the lines before the first
842/// `@tag`, [`LineRole::Prose`]) as roxygen `@description` text, its links
843/// treated per the crate's [`ProseLinks`] setting (passed in, so the unit tests
844/// need no manifest; [`roxygen_tags_with`] passes `Strip` for a block that
845/// renders no page).
846///
847/// The lines ([`doc_lines`]) keep their line breaks, blank lines (roxygen2
848/// paragraph breaks, rendered as bare `#'` lines by `push_roxygen_tags`) and
849/// indentation, so a markdown list, a nested item or a fenced block reaches
850/// roxygen2 as written. Leading and trailing blank lines are dropped. With
851/// [`ProseLinks::Strip`] every link is neutralized; with [`ProseLinks::Keep`]
852/// only the rustdoc-only ones are ([`sanitize_prose_links`]).
853///
854/// Returns `None` when the block has no leading prose (empty, or starts with a `@tag`),
855/// so tag-led blocks never gain a spurious `@description`.
856fn leading_prose_from_attrs(attrs: &[syn::Attribute], links: ProseLinks) -> Option<String> {
857    let lines = doc_lines(attrs);
858    let prose: Vec<&str> = lines
859        .iter()
860        .zip(classify(&lines))
861        .take_while(|(_, role)| *role == LineRole::Prose)
862        .map(|(line, _)| line.text.as_str())
863        .collect();
864    let first = prose.iter().position(|line| !line.is_empty())?;
865    let last = prose.iter().rposition(|line| !line.is_empty())?;
866    let prose = &prose[first..=last];
867    Some(match links {
868        ProseLinks::Strip => sanitize_prose_links(prose, Neutralize::AllLinks),
869        ProseLinks::Keep => sanitize_prose_links(prose, Neutralize::RustdocOnly),
870    })
871}
872
873/// An explicit tag with the `links` of its text neutralized: the rustdoc-only
874/// ones ([`Neutralize::RustdocOnly`]), or all of them in a block that renders
875/// no page ([`Neutralize::AllLinks`]). A code tag ([`CODE_TAGS`]) stays as
876/// written.
877fn neutralize_tag_links(tag: String, links: Neutralize) -> String {
878    if roxygen_tag_name(&tag).is_some_and(|name| CODE_TAGS.contains(&name)) {
879        return tag;
880    }
881    sanitize_prose_links(&tag.lines().collect::<Vec<_>>(), links)
882}
883
884/// Which `[...]` links [`sanitize_roxygen_links`] reduces to their text.
885#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for Neutralize { }
#[automatically_derived]
impl ::core::clone::Clone for Neutralize {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for Neutralize { }Copy, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for Neutralize { }
#[automatically_derived]
impl ::core::cmp::PartialEq for Neutralize {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for Neutralize { }Eq)]
886enum Neutralize {
887    /// Every link: leading prose under [`ProseLinks::Strip`], and the prose
888    /// and tag text of a block that renders no page ([`RdPage`]).
889    AllLinks,
890    /// Only a link whose target roxygen2 can never resolve
891    /// ([`is_rustdoc_only_target`]): leading prose under [`ProseLinks::Keep`],
892    /// and explicit tag text ([`neutralize_tag_links`]), in a block that
893    /// renders a page.
894    RustdocOnly,
895}
896
897/// [`sanitize_roxygen_links`] over prose lines, one paragraph (the lines
898/// between blank lines) at a time, joined back with their line breaks.
899///
900/// A link whose text and target sit on two lines of one paragraph is still
901/// found. CommonMark code spans do not cross a blank line, so an unbalanced
902/// backtick cannot switch neutralizing off beyond its paragraph. A fenced
903/// block (```` ``` ```` or `~~~`, blank lines included) is code and passes
904/// through untouched, so `x[i]` there is never rewritten.
905fn sanitize_prose_links(lines: &[&str], links: Neutralize) -> String {
906    fn flush(paragraph: &mut Vec<&str>, out: &mut Vec<String>, links: Neutralize) {
907        if !paragraph.is_empty() {
908            out.push(sanitize_roxygen_links(&paragraph.join("\n"), links));
909            paragraph.clear();
910        }
911    }
912
913    let mut out: Vec<String> = Vec::new();
914    let mut paragraph: Vec<&str> = Vec::new();
915    let mut open_fence: Option<&str> = None;
916    for &line in lines {
917        if let Some(open) = open_fence {
918            if fence_marker(line).is_some_and(|close| closes_fence(line, close, open)) {
919                open_fence = None;
920            }
921            out.push(line.to_string());
922        } else if let Some(open) = fence_marker(line) {
923            flush(&mut paragraph, &mut out, links);
924            open_fence = Some(open);
925            out.push(line.to_string());
926        } else if line.is_empty() {
927            flush(&mut paragraph, &mut out, links);
928            out.push(String::new());
929        } else {
930            paragraph.push(line);
931        }
932    }
933    flush(&mut paragraph, &mut out, links);
934    out.join("\n")
935}
936
937/// The fence run (3 or more `` ` `` or `~`) a CommonMark code-fence line
938/// starts with, after at most 3 spaces of indent.
939fn fence_marker(line: &str) -> Option<&str> {
940    let body = line.trim_start_matches(' ');
941    if line.len() - body.len() > 3 {
942        return None;
943    }
944    let fence_char = body.chars().next().filter(|c| #[allow(non_exhaustive_omitted_patterns)] match c {
    '`' | '~' => true,
    _ => false,
}matches!(c, '`' | '~'))?;
945    let run = body.len() - body.trim_start_matches(fence_char).len();
946    (run >= 3).then(|| &body[..run])
947}
948
949/// Whether `line`, whose fence run is `close`, ends the block `open` began: the
950/// same character, at least as long, and nothing after it.
951fn closes_fence(line: &str, close: &str, open: &str) -> bool {
952    close.starts_with(&open[..1])
953        && close.len() >= open.len()
954        && line.trim_start_matches(' ')[close.len()..]
955            .trim()
956            .is_empty()
957}
958
959/// Neutralize rustdoc intra-doc link syntax so prose is valid roxygen2 markdown.
960///
961/// rustdoc `[`Foo`]` / `[Foo]` / `[text][target]` are intra-doc links resolved
962/// against *Rust* items by `cargo doc`. roxygen2 (markdown on) reads the same
963/// `[...]` as an R `\link{}` to a *help topic*, which can't resolve. We strip the
964/// link brackets down to the visible text (keeping any `` `code` `` span), while
965/// leaving genuine markdown links `[text](url)` — recognized by the `]( ` that
966/// follows — untouched. With [`Neutralize::AllLinks`] a roxygen2 link
967/// (`[other_fn()]`) is stripped too: the syntax is rustdoc's as well, so a
968/// crate writing for roxygen2 opts out with [`ProseLinks::Keep`]. With
969/// [`Neutralize::RustdocOnly`] only a link whose target roxygen2 can never
970/// resolve is stripped ([`is_rustdoc_only_target`]); the rest stay as written.
971///
972/// rustdoc's inline form `[text](crate::x)` is the one `[text](...)` that loses
973/// its link, under either scope: its destination is a rustdoc-only Rust path
974/// ([`inline_rustdoc_destination`]), which roxygen2 would render as a dead
975/// `\href{}`.
976fn sanitize_roxygen_links(s: &str, links: Neutralize) -> String {
977    let bytes = s.as_bytes();
978    let mut out = String::with_capacity(s.len());
979    let mut i = 0;
980    let mut in_code = false;
981    while i < s.len() {
982        if bytes[i] == b'`' {
983            // Inline code span: markdown (and roxygen2) never parse `[...]`
984            // inside backticks as a link, so neither do we — stripping there
985            // would corrupt code like `x[i]`.
986            in_code = !in_code;
987            out.push('`');
988            i += 1;
989            continue;
990        }
991        if !in_code && bytes[i] == b'[' {
992            // `\[` is a backslash-escaped literal bracket (idiomatic rustdoc
993            // for suppressing intra-doc links, e.g. `Box<\[u8\]>`). Not a link
994            // opener — pass it through; roxygen2's markdown unescapes it.
995            if i > 0 && bytes[i - 1] == b'\\' {
996                out.push('[');
997                i += 1;
998                continue;
999            }
1000            // `[` is ASCII, so `i + 1` is a char boundary.
1001            if let Some(close_rel) = s[i + 1..].find(']') {
1002                let close = i + 1 + close_rel;
1003                let inner = &s[i + 1..close];
1004                // `[text](dest)` — an inline link. A rustdoc-only Rust path
1005                // (`[text](crate::x)`, [`inline_rustdoc_destination`]) keeps
1006                // its text alone under every scope. Anything else is a real
1007                // markdown link: emit `[` literally and let the inner text +
1008                // `](url)` flow through unchanged.
1009                if bytes.get(close + 1) == Some(&b'(') {
1010                    if let Some(end) = inline_rustdoc_destination(s, close + 2)
1011                        && !inner.contains('[')
1012                    {
1013                        out.push_str(inner);
1014                        i = end;
1015                    } else {
1016                        out.push('[');
1017                        i += 1;
1018                    }
1019                    continue;
1020                }
1021                // `[text][target]` — reference link; `[text]` — shortcut link,
1022                // its own target.
1023                let (target, end) = match bytes.get(close + 1) {
1024                    Some(b'[') => match s[close + 2..].find(']') {
1025                        Some(t_rel) => (&s[close + 2..close + 2 + t_rel], close + 2 + t_rel + 1),
1026                        None => (inner, close + 1),
1027                    },
1028                    _ => (inner, close + 1),
1029                };
1030                // Keep `text`, drop the brackets and any `[target]`.
1031                if links == Neutralize::AllLinks || is_rustdoc_only_target(target) {
1032                    out.push_str(inner);
1033                } else {
1034                    out.push_str(&s[i..end]);
1035                }
1036                i = end;
1037                continue;
1038            }
1039        }
1040        let ch = s[i..].chars().next().unwrap();
1041        out.push(ch);
1042        i += ch.len_utf8();
1043    }
1044    out
1045}
1046
1047/// The end (past its `)`) of the destination of an inline link `[text](dest)`
1048/// starting at byte `start` (just past the `(`), when that destination was
1049/// written for rustdoc: a Rust path ([`is_rust_path`]) containing `::`, with
1050/// an optional disambiguator prefix ([`strip_rustdoc_disambiguator`]), such as
1051/// `crate::x`, `serde::Serialize` or `fn@Self::new`.
1052///
1053/// rustdoc reads `[text](crate::x)` as an intra-doc link; roxygen2 reads it as
1054/// a web link and renders `\href{crate::x}{text}`, a dead link, without a
1055/// warning (#1744). roxygen2 never reads the inline form as an R link, so the
1056/// root of the path does not matter (`dplyr::bind_rows` is as dead as
1057/// `crate::x`, #1756). `None` for an unclosed destination, one that spans
1058/// whitespace, any URL (`https://a.b/c::d`, `./x.html`), and a path without
1059/// `::`: `Foo`, which a relative URL may spell.
1060fn inline_rustdoc_destination(s: &str, start: usize) -> Option<usize> {
1061    // The matching `)`: a destination may hold balanced parentheses, as the
1062    // `()` of `crate::f()` does.
1063    let mut depth = 1usize;
1064    let close = start
1065        + s[start..].find(|c: char| {
1066            match c {
1067                '(' => depth += 1,
1068                ')' => depth -= 1,
1069                _ => {}
1070            }
1071            depth == 0 || c.is_whitespace()
1072        })?;
1073    if s.as_bytes()[close] != b')' {
1074        return None;
1075    }
1076    let dest = &s[start..close];
1077    let path = strip_rustdoc_disambiguator(dest);
1078    (is_rust_path(path) && path.contains("::")).then_some(close + 1)
1079}
1080
1081/// `dest` without a rustdoc disambiguator prefix (`fn@crate::x` -> `crate::x`).
1082/// rustdoc's prefixes are the item kinds below; any other `word@rest` is left
1083/// whole and so fails [`is_rust_path`].
1084fn strip_rustdoc_disambiguator(dest: &str) -> &str {
1085    const KINDS: &[&str] = &[
1086        "struct",
1087        "enum",
1088        "trait",
1089        "union",
1090        "mod",
1091        "module",
1092        "const",
1093        "constant",
1094        "static",
1095        "function",
1096        "fn",
1097        "method",
1098        "derive",
1099        "type",
1100        "tyalias",
1101        "typealias",
1102        "macro",
1103        "prim",
1104        "primitive",
1105        "field",
1106        "variant",
1107        "value",
1108        "namespace",
1109        "attr",
1110    ];
1111    match dest.split_once('@') {
1112        Some((kind, rest)) if KINDS.contains(&kind) => rest,
1113        _ => dest,
1114    }
1115}
1116
1117/// Whether `dest` is a Rust path as an inline link destination writes it:
1118/// identifiers joined by `::`, with an optional `()` or `!` suffix. A URL with
1119/// a `/`, `.`, `#`, `?` or lone `:` never is one, even with a `::` in it
1120/// (`https://a.b/c::d`). A bare `Foo` can be either, so the caller also asks
1121/// for a `::`.
1122fn is_rust_path(dest: &str) -> bool {
1123    let path = dest
1124        .strip_suffix("()")
1125        .or_else(|| dest.strip_suffix('!'))
1126        .unwrap_or(dest);
1127    path.split("::").all(|segment| {
1128        let mut chars = segment.chars();
1129        chars
1130            .next()
1131            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1132            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1133    })
1134}
1135
1136/// Whether roxygen2 can never resolve the link target `target`, so the link
1137/// was written for rustdoc.
1138///
1139/// roxygen2 reads `pkg::topic` as a link into the R package `pkg`, everything
1140/// before the last `::`, and warns "refers to un-installed package" when no
1141/// such package is installed. A target is rustdoc-only when that `pkg` is a
1142/// Rust path root (`crate`, `self`, `Self`) or cannot be an R package name
1143/// (`a::b`, `my_mod`; see [`crate::naming::is_r_package_name`]). Backticks around the target
1144/// are ignored, as both readers do. Anything else may be an R link, so it is
1145/// left alone: `[topic]`, `` [`topic`] ``, `[fn()]` and `[pkg::topic]`, which
1146/// roxygen2 documents, and so `` [`Type::method`] `` (`Type` can be a package:
1147/// `R6`, `S7`, `Matrix`) and `[super::x]` (`super` is on CRAN).
1148fn is_rustdoc_only_target(target: &str) -> bool {
1149    let target = target.trim();
1150    let target = target
1151        .strip_prefix('`')
1152        .and_then(|t| t.strip_suffix('`'))
1153        .unwrap_or(target);
1154    let Some((pkg, _)) = target.rsplit_once("::") else {
1155        return false;
1156    };
1157    #[allow(non_exhaustive_omitted_patterns)] match pkg {
    "crate" | "self" | "Self" => true,
    _ => false,
}matches!(pkg, "crate" | "self" | "Self") || !crate::naming::is_r_package_name(pkg)
1158}
1159
1160/// Check for conflicts between explicit `@title`/`@description` tags and implicit values.
1161///
1162/// When the `doc-lint` feature is enabled, returns tokens that generate compile-time
1163/// deprecation warnings if explicit roxygen tags differ from the implicit values
1164/// derived from the doc comment structure.
1165///
1166/// The returned tokens should be appended to the macro expansion output.
1167#[cfg(feature = "doc-lint")]
1168pub(crate) fn doc_conflict_warnings(
1169    attrs: &[syn::Attribute],
1170    _span: proc_macro2::Span,
1171) -> proc_macro2::TokenStream {
1172    use quote::quote;
1173
1174    // Raw parse only: `roxygen_tags_from_attrs` synthesizes a `@description`
1175    // from leading prose, which this lint must not mistake for an
1176    // author-written tag (#1172).
1177    let tags = explicit_roxygen_tags_from_attrs(attrs);
1178    let mut warnings = proc_macro2::TokenStream::new();
1179
1180    // Check @title conflict
1181    if let Some(explicit) = find_tag_value(&tags, "title")
1182        && let Some(implicit) = implicit_title_from_attrs(attrs)
1183        && normalize_for_comparison(explicit) != normalize_for_comparison(&implicit)
1184    {
1185        let msg = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("miniextendr doc-lint: explicit @title differs from first doc line. R\'s roxygen2 uses the first line as the title. implicit: \"{0}\", explicit @title: \"{1}\"",
                implicit, explicit))
    })format!(
1186            "miniextendr doc-lint: explicit @title differs from first doc line. \
1187             R's roxygen2 uses the first line as the title. \
1188             implicit: \"{}\", explicit @title: \"{}\"",
1189            implicit, explicit
1190        );
1191        warnings.extend({
    let mut _s = ::quote::__private::TokenStream::new();
    ::quote::__private::push_ident(&mut _s, "const");
    ::quote::__private::push_underscore(&mut _s);
    ::quote::__private::push_colon(&mut _s);
    ::quote::__private::push_group(&mut _s,
        ::quote::__private::Delimiter::Parenthesis,
        ::quote::__private::TokenStream::new());
    ::quote::__private::push_eq(&mut _s);
    ::quote::__private::push_group(&mut _s,
        ::quote::__private::Delimiter::Brace,
        {
            let mut _s = ::quote::__private::TokenStream::new();
            ::quote::__private::push_pound(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    ::quote::__private::push_ident(&mut _s, "deprecated");
                    ::quote::__private::push_group(&mut _s,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            ::quote::__private::push_ident(&mut _s, "note");
                            ::quote::__private::push_eq(&mut _s);
                            ::quote::ToTokens::to_tokens(&msg, &mut _s);
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    ::quote::__private::push_ident(&mut _s, "doc");
                    ::quote::__private::push_group(&mut _s,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            ::quote::__private::push_ident(&mut _s, "hidden");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    ::quote::__private::push_ident(&mut _s, "allow");
                    ::quote::__private::push_group(&mut _s,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            ::quote::__private::push_ident(&mut _s, "dead_code");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_ident(&mut _s, "const");
            ::quote::__private::push_ident(&mut _s,
                "MINIEXTENDR_DOC_LINT_TITLE");
            ::quote::__private::push_colon(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Parenthesis,
                ::quote::__private::TokenStream::new());
            ::quote::__private::push_eq(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Parenthesis,
                ::quote::__private::TokenStream::new());
            ::quote::__private::push_semi(&mut _s);
            ::quote::__private::push_ident(&mut _s, "let");
            ::quote::__private::push_underscore(&mut _s);
            ::quote::__private::push_eq(&mut _s);
            ::quote::__private::push_ident(&mut _s,
                "MINIEXTENDR_DOC_LINT_TITLE");
            ::quote::__private::push_semi(&mut _s);
            _s
        });
    ::quote::__private::push_semi(&mut _s);
    _s
}quote! {
1192            const _: () = {
1193                #[deprecated(note = #msg)]
1194                #[doc(hidden)]
1195                #[allow(dead_code)]
1196                const MINIEXTENDR_DOC_LINT_TITLE: () = ();
1197                let _ = MINIEXTENDR_DOC_LINT_TITLE;
1198            };
1199        });
1200    }
1201
1202    // Check @description conflict
1203    if let Some(explicit) = find_tag_value(&tags, "description")
1204        && let Some(implicit) = implicit_description_from_attrs(attrs)
1205        && normalize_for_comparison(explicit) != normalize_for_comparison(&implicit)
1206    {
1207        let msg = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("miniextendr doc-lint: explicit @description differs from first paragraph. R\'s roxygen2 uses the first paragraph as the description. implicit: \"{0}\", explicit @description: \"{1}\"",
                implicit, explicit))
    })format!(
1208            "miniextendr doc-lint: explicit @description differs from first paragraph. \
1209             R's roxygen2 uses the first paragraph as the description. \
1210             implicit: \"{}\", explicit @description: \"{}\"",
1211            implicit, explicit
1212        );
1213        warnings.extend({
    let mut _s = ::quote::__private::TokenStream::new();
    ::quote::__private::push_ident(&mut _s, "const");
    ::quote::__private::push_underscore(&mut _s);
    ::quote::__private::push_colon(&mut _s);
    ::quote::__private::push_group(&mut _s,
        ::quote::__private::Delimiter::Parenthesis,
        ::quote::__private::TokenStream::new());
    ::quote::__private::push_eq(&mut _s);
    ::quote::__private::push_group(&mut _s,
        ::quote::__private::Delimiter::Brace,
        {
            let mut _s = ::quote::__private::TokenStream::new();
            ::quote::__private::push_pound(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    ::quote::__private::push_ident(&mut _s, "deprecated");
                    ::quote::__private::push_group(&mut _s,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            ::quote::__private::push_ident(&mut _s, "note");
                            ::quote::__private::push_eq(&mut _s);
                            ::quote::ToTokens::to_tokens(&msg, &mut _s);
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    ::quote::__private::push_ident(&mut _s, "doc");
                    ::quote::__private::push_group(&mut _s,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            ::quote::__private::push_ident(&mut _s, "hidden");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    ::quote::__private::push_ident(&mut _s, "allow");
                    ::quote::__private::push_group(&mut _s,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            ::quote::__private::push_ident(&mut _s, "dead_code");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_ident(&mut _s, "const");
            ::quote::__private::push_ident(&mut _s,
                "MINIEXTENDR_DOC_LINT_DESC");
            ::quote::__private::push_colon(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Parenthesis,
                ::quote::__private::TokenStream::new());
            ::quote::__private::push_eq(&mut _s);
            ::quote::__private::push_group(&mut _s,
                ::quote::__private::Delimiter::Parenthesis,
                ::quote::__private::TokenStream::new());
            ::quote::__private::push_semi(&mut _s);
            ::quote::__private::push_ident(&mut _s, "let");
            ::quote::__private::push_underscore(&mut _s);
            ::quote::__private::push_eq(&mut _s);
            ::quote::__private::push_ident(&mut _s,
                "MINIEXTENDR_DOC_LINT_DESC");
            ::quote::__private::push_semi(&mut _s);
            _s
        });
    ::quote::__private::push_semi(&mut _s);
    _s
}quote! {
1214            const _: () = {
1215                #[deprecated(note = #msg)]
1216                #[doc(hidden)]
1217                #[allow(dead_code)]
1218                const MINIEXTENDR_DOC_LINT_DESC: () = ();
1219                let _ = MINIEXTENDR_DOC_LINT_DESC;
1220            };
1221        });
1222    }
1223
1224    warnings
1225}
1226
1227/// No-op when doc-lint feature is disabled.
1228#[cfg(not(feature = "doc-lint"))]
1229pub(crate) fn doc_conflict_warnings(
1230    _attrs: &[syn::Attribute],
1231    _span: proc_macro2::Span,
1232) -> proc_macro2::TokenStream {
1233    proc_macro2::TokenStream::new()
1234}
1235
1236/// Roxygen tags that only make sense on individual methods, not on impl blocks.
1237///
1238/// - `@param` — impl blocks have no parameters (except for R6 class-level param docs,
1239///   which roxygen2 8.0.0 inherits into all methods; those are exempted by
1240///   [`strip_method_tags_r6`]).
1241/// - `@return` / `@returns` — impl blocks have no return value.
1242/// - `@examples` — examples belong on the method that is being demonstrated.
1243/// - `@export` — redundant: export for class-level docs is handled by
1244///   `ClassDocBuilder`, which emits `@export` based on the impl block's
1245///   `internal` / `noexport` attrs, not on user-supplied roxygen.
1246const METHOD_ONLY_TAGS: &[&str] = &["param", "return", "returns", "examples", "export"];
1247
1248/// Tags stripped from impl-block docs for R6 classes — same as `METHOD_ONLY_TAGS`
1249/// minus `"param"`, since roxygen2 8.0.0 inherits class-level `@param` tags into
1250/// all R6 methods and strips them from the rendered method entries automatically.
1251/// This means `/// @param breed …` on an R6 impl block is valid and intentional,
1252/// not a misplaced method-only tag. Keeping them avoids both the compile warning
1253/// and the resulting `(no documentation available)` placeholder on subclass ctors.
1254const METHOD_ONLY_TAGS_R6: &[&str] = &["return", "returns", "examples", "export"];
1255
1256/// Extract the tag name from a roxygen line (everything between `@` and the
1257/// first whitespace character). Returns `None` for lines that don't start with
1258/// a tag.
1259pub(crate) fn roxygen_tag_name(tag: &str) -> Option<&str> {
1260    let rest = tag.trim_start().strip_prefix('@')?;
1261    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
1262    Some(&rest[..end])
1263}
1264
1265/// Whether a method block without `\usage` (an env method `Type$method`, a
1266/// trait method `Type$Trait$method`) forwards the author tag `tag`: every tag
1267/// but a bare `@export`. On such a block roxygen2's namespace roclet turns a
1268/// bare `@export` into `export("Type$method")`, and R refuses to load the
1269/// namespace ("undefined exports"). The member is reachable through its
1270/// exported class, so exporting it is never valid. An `@export <symbol>`
1271/// names its own target and is forwarded.
1272pub(crate) fn forwarded_member_tag(tag: &str) -> bool {
1273    !tag.trim_start()
1274        .strip_prefix("@export")
1275        .is_some_and(|rest| rest.trim().is_empty())
1276}
1277
1278/// Filter out method-specific roxygen tags from impl-block-level docs and emit
1279/// compile warnings for each stripped tag.
1280///
1281/// Method-specific tags (`@param`, `@return`, `@returns`, `@examples`,
1282/// `@export`) on an impl block are meaningless — they belong on individual
1283/// methods, or (for `@export`) are emitted by `ClassDocBuilder`. When users
1284/// put them on impl blocks, the tags leak into the class-level Rd file where
1285/// R CMD check warns about "documented arguments not in \\usage" and similar.
1286///
1287/// Each stripped tag's warning is wrapped in its own anonymous
1288/// `const _: () = { ... };` block (same pattern as [`doc_conflict_warnings`]),
1289/// so the inner const names can stay fixed — an anonymous const block is a
1290/// fresh item scope every expansion, so two `#[miniextendr] impl Foo` blocks
1291/// on the *same* type (e.g. an inherent impl plus a trait impl) never collide
1292/// with `error[E0428]: the name ... is defined multiple times`, without any
1293/// per-block disambiguator (#1118).
1294///
1295/// Returns the filtered tags and a TokenStream of deprecation warnings for
1296/// each stripped tag. The caller should append the warnings to its output so
1297/// the user sees them at compile time.
1298pub(crate) fn strip_method_tags(
1299    tags: &[String],
1300    type_name: &str,
1301    span: proc_macro2::Span,
1302) -> (Vec<String>, proc_macro2::TokenStream) {
1303    strip_tags_in(tags, type_name, span, METHOD_ONLY_TAGS)
1304}
1305
1306/// Shared body of [`strip_method_tags`] and [`strip_method_tags_r6`]: drop
1307/// every tag whose name is in `method_only` and emit one warning const scope
1308/// per dropped tag. The two public entry points differ only in the tag table.
1309fn strip_tags_in(
1310    tags: &[String],
1311    type_name: &str,
1312    span: proc_macro2::Span,
1313    method_only: &[&str],
1314) -> (Vec<String>, proc_macro2::TokenStream) {
1315    use quote::quote_spanned;
1316
1317    let mut filtered = Vec::new();
1318    let mut warnings = proc_macro2::TokenStream::new();
1319
1320    for tag in tags {
1321        let Some(name) = roxygen_tag_name(tag) else {
1322            filtered.push(tag.clone());
1323            continue;
1324        };
1325        if !method_only.contains(&name) {
1326            // Keeps unrecognised tags (and, for R6, @param) without warning.
1327            filtered.push(tag.clone());
1328            continue;
1329        }
1330        let msg = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("miniextendr: @{0} on impl block `{1}` has no effect — move it to the method. Tag: {2}",
                name, type_name, tag.trim()))
    })format!(
1331            "miniextendr: @{} on impl block `{}` has no effect — move it to the method. Tag: {}",
1332            name,
1333            type_name,
1334            tag.trim()
1335        );
1336        warnings.extend({
    let mut _s = ::quote::__private::TokenStream::new();
    let _span: ::quote::__private::Span =
        ::quote::__private::get_span(span).__into_span();
    ::quote::__private::push_ident_spanned(&mut _s, _span, "const");
    ::quote::__private::push_underscore_spanned(&mut _s, _span);
    ::quote::__private::push_colon_spanned(&mut _s, _span);
    ::quote::__private::push_group_spanned(&mut _s, _span,
        ::quote::__private::Delimiter::Parenthesis,
        {
            let _: ::quote::__private::Span =
                ::quote::__private::get_span(_span).__into_span();
            ::quote::__private::TokenStream::new()
        });
    ::quote::__private::push_eq_spanned(&mut _s, _span);
    ::quote::__private::push_group_spanned(&mut _s, _span,
        ::quote::__private::Delimiter::Brace,
        {
            let mut _s = ::quote::__private::TokenStream::new();
            let _span: ::quote::__private::Span =
                ::quote::__private::get_span(_span).__into_span();
            ::quote::__private::push_pound_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    let _span: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::push_ident_spanned(&mut _s, _span,
                        "deprecated");
                    ::quote::__private::push_group_spanned(&mut _s, _span,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            let _span: ::quote::__private::Span =
                                ::quote::__private::get_span(_span).__into_span();
                            ::quote::__private::push_ident_spanned(&mut _s, _span,
                                "note");
                            ::quote::__private::push_eq_spanned(&mut _s, _span);
                            ::quote::ToTokens::to_tokens(&msg, &mut _s);
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    let _span: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::push_ident_spanned(&mut _s, _span,
                        "doc");
                    ::quote::__private::push_group_spanned(&mut _s, _span,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            let _span: ::quote::__private::Span =
                                ::quote::__private::get_span(_span).__into_span();
                            ::quote::__private::push_ident_spanned(&mut _s, _span,
                                "hidden");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    let _span: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::push_ident_spanned(&mut _s, _span,
                        "allow");
                    ::quote::__private::push_group_spanned(&mut _s, _span,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            let _span: ::quote::__private::Span =
                                ::quote::__private::get_span(_span).__into_span();
                            ::quote::__private::push_ident_spanned(&mut _s, _span,
                                "dead_code");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_ident_spanned(&mut _s, _span, "const");
            ::quote::__private::push_ident_spanned(&mut _s, _span,
                "_MINIEXTENDR_IMPL_METHOD_TAG_WARN");
            ::quote::__private::push_colon_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Parenthesis,
                {
                    let _: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::TokenStream::new()
                });
            ::quote::__private::push_eq_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Parenthesis,
                {
                    let _: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::TokenStream::new()
                });
            ::quote::__private::push_semi_spanned(&mut _s, _span);
            ::quote::__private::push_pound_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    let _span: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::push_ident_spanned(&mut _s, _span,
                        "doc");
                    ::quote::__private::push_group_spanned(&mut _s, _span,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            let _span: ::quote::__private::Span =
                                ::quote::__private::get_span(_span).__into_span();
                            ::quote::__private::push_ident_spanned(&mut _s, _span,
                                "hidden");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_pound_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Bracket,
                {
                    let mut _s = ::quote::__private::TokenStream::new();
                    let _span: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::push_ident_spanned(&mut _s, _span,
                        "allow");
                    ::quote::__private::push_group_spanned(&mut _s, _span,
                        ::quote::__private::Delimiter::Parenthesis,
                        {
                            let mut _s = ::quote::__private::TokenStream::new();
                            let _span: ::quote::__private::Span =
                                ::quote::__private::get_span(_span).__into_span();
                            ::quote::__private::push_ident_spanned(&mut _s, _span,
                                "dead_code");
                            _s
                        });
                    _s
                });
            ::quote::__private::push_ident_spanned(&mut _s, _span, "const");
            ::quote::__private::push_ident_spanned(&mut _s, _span,
                "_MINIEXTENDR_IMPL_METHOD_TAG_USE");
            ::quote::__private::push_colon_spanned(&mut _s, _span);
            ::quote::__private::push_group_spanned(&mut _s, _span,
                ::quote::__private::Delimiter::Parenthesis,
                {
                    let _: ::quote::__private::Span =
                        ::quote::__private::get_span(_span).__into_span();
                    ::quote::__private::TokenStream::new()
                });
            ::quote::__private::push_eq_spanned(&mut _s, _span);
            ::quote::__private::push_ident_spanned(&mut _s, _span,
                "_MINIEXTENDR_IMPL_METHOD_TAG_WARN");
            ::quote::__private::push_semi_spanned(&mut _s, _span);
            _s
        });
    ::quote::__private::push_semi_spanned(&mut _s, _span);
    _s
}quote_spanned! { span =>
1337            const _: () = {
1338                #[deprecated(note = #msg)]
1339                #[doc(hidden)]
1340                #[allow(dead_code)]
1341                const _MINIEXTENDR_IMPL_METHOD_TAG_WARN: () = ();
1342                #[doc(hidden)]
1343                #[allow(dead_code)]
1344                const _MINIEXTENDR_IMPL_METHOD_TAG_USE: () = _MINIEXTENDR_IMPL_METHOD_TAG_WARN;
1345            };
1346        });
1347    }
1348
1349    (filtered, warnings)
1350}
1351
1352/// Extract the set of parameter names declared via `@param` in a list of roxygen tags.
1353///
1354/// For each `@param <names> <desc>` tag in `tags`, extracts `<names>` and inserts
1355/// each comma-separated name into the returned `HashSet`. roxygen2 supports
1356/// documenting several params in one tag (`@param a,b,c desc` documents `a`,
1357/// `b`, and `c`) — the name token is split on `,` (and each piece trimmed
1358/// defensively, though roxygen2's own syntax has no spaces around the commas)
1359/// so multi-name tags don't collapse to a single name. Used by R6 class
1360/// generators to build the set of class-level params so method param loops
1361/// can suppress `(no documentation available)` for names already covered at
1362/// class level (roxygen2 8.0.0 inherits class-level `@param` tags into all
1363/// methods automatically).
1364pub(crate) fn extract_param_names(tags: &[String]) -> HashSet<String> {
1365    let mut names = HashSet::new();
1366    for tag in tags {
1367        let Some(names_token) = param_names_token(tag) else {
1368            continue;
1369        };
1370        for name in names_token.split(',') {
1371            let name = name.trim();
1372            if !name.is_empty() {
1373                names.insert(name.to_string());
1374            }
1375        }
1376    }
1377    names
1378}
1379
1380/// Returns `true` if `name` is documented by any `@param` tag in `tags`.
1381///
1382/// A tag documents `name` if `name` is exactly one of the comma-separated
1383/// names in its `@param <names> <desc>` name token (see
1384/// [`extract_param_names`]). This is an **exact-membership** check, not a
1385/// prefix match — `starts_with(&format!("@param {name}"))` was the previous
1386/// (buggy) approach: it misses every name after the first in a comma-list
1387/// tag (`@param a,b,c desc` "documents" only `a`, so `b`/`c` get a spurious
1388/// `(no documentation available)` filler that roxygen2 then merges into a
1389/// duplicate `\item{b}` and `\item{c}` in the rendered Rd — an
1390/// `R CMD check` `checking Rd \usage sections` WARNING), and it also
1391/// false-positives on names that are prefixes of other names (`@param x2
1392/// desc` looks like it documents `x` too).
1393pub(crate) fn param_documented(tags: &[String], name: &str) -> bool {
1394    tags.iter().any(|tag| tag_documents_param(tag, name))
1395}
1396
1397/// Like [`param_documented`] but returns the matching tag itself rather than
1398/// a bool, for callers that reuse the tag's rendered text verbatim (S7
1399/// constructor param doc forwarding pushes the found tag as-is).
1400pub(crate) fn find_param_tag<'a>(tags: &'a [String], name: &str) -> Option<&'a String> {
1401    tags.iter().find(|tag| tag_documents_param(tag, name))
1402}
1403
1404/// Shared predicate: does this single `@param` tag document `name`?
1405fn tag_documents_param(tag: &str, name: &str) -> bool {
1406    let Some(names_token) = param_names_token(tag) else {
1407        return false;
1408    };
1409    names_token.split(',').any(|n| n.trim() == name)
1410}
1411
1412/// Returns the `@param` name token (e.g. `a,b,c` in `@param a,b,c desc`) of a
1413/// single tag, or `None` if `tag` isn't an `@param` tag.
1414fn param_names_token(tag: &str) -> Option<&str> {
1415    let trimmed = tag.trim_start();
1416    let rest = trimmed.strip_prefix("@param ")?;
1417    rest.split_whitespace().next()
1418}
1419
1420// region: shared pages (#1590)
1421
1422/// The topic named by the tag list's own `@describeIn <topic> <description>`
1423/// tag, if it has one.
1424pub(crate) fn describe_in_topic(tags: &[String]) -> Option<&str> {
1425    tags.iter().find_map(|t| {
1426        let rest = t.trim_start().strip_prefix("@describeIn")?;
1427        if rest.starts_with(char::is_whitespace) {
1428            rest.split_whitespace().next()
1429        } else {
1430            None
1431        }
1432    })
1433}
1434
1435/// Whether the author's tags put the block on a topic other than
1436/// `default_page`: `@describeIn topic ...`, or an `@rdname topic` that names
1437/// another page. The block then joins a topic whose own block (usually the
1438/// author's, often in R) documents the page, so generated lines that claim the
1439/// page (a structural `@param`, the page `\name` and title) must give way to it.
1440///
1441/// `tags` must be the author's own tags (doc comment or `doc = "..."`), and
1442/// `default_page` the page the framework puts the block on anyway: a class
1443/// generator passes the class name, so a redundant author `@rdname <Class>`
1444/// counts as no page tag. A standalone function passes `None` (whether an
1445/// `@rdname` names its own page or its file's stem page is only known when
1446/// the wrapper registry writes the file), so any `@rdname` counts there.
1447pub(crate) fn joins_author_topic(tags: &[String], default_page: Option<&str>) -> bool {
1448    describe_in_topic(tags).is_some()
1449        || rdname_value(tags).is_some_and(|topic| Some(topic) != default_page)
1450}
1451
1452/// The page a method's block lands on: its `@describeIn` destination (roxygen2
1453/// files a `@describeIn` block under the destination's topic), its own
1454/// `@rdname`, else `default_page` (the class page). A companion block that
1455/// must share the method's page (the S3 generic block, whose class-qualified
1456/// `@name` is also the method's alias) gets `@rdname` this.
1457pub(crate) fn method_page<'a>(tags: &'a [String], default_page: &'a str) -> &'a str {
1458    describe_in_topic(tags)
1459        .or_else(|| rdname_value(tags))
1460        .unwrap_or(default_page)
1461}
1462
1463/// Whether the author's tags inherit the parameter docs from another topic:
1464/// `@inheritParams source`, or `@inherit source` whose field list includes
1465/// params (roxygen2 inherits every field when none is listed). roxygen2 fills
1466/// only the arguments the merged topic leaves undocumented, so a generated
1467/// line would block the inheritance.
1468fn inherits_params(tags: &[String]) -> bool {
1469    tags.iter().any(|tag| match roxygen_tag_name(tag) {
1470        Some("inheritParams") => true,
1471        Some("inherit") => {
1472            let mut fields = tag.split_whitespace().skip(2).peekable(); // `@inherit`, source
1473            fields.peek().is_none() || fields.any(|field| field == "params")
1474        }
1475        _ => false,
1476    })
1477}
1478
1479/// Returns `true` when the author's roxygen block takes its argument
1480/// documentation from another block, so the generators must not add `@param`
1481/// lines for the arguments it leaves undocumented (#1590):
1482///
1483/// - the block joins another topic ([`joins_author_topic`]: `@rdname topic`,
1484///   `@describeIn topic ...`), whose own block documents the arguments.
1485///   roxygen2 keeps one entry per argument name on a merged page, and a
1486///   generated line such as `(no documentation available)` is the entry that
1487///   survives, so it would replace the topic's description (and add a second
1488///   entry beside a grouped `@param a,b`);
1489/// - the block inherits them ([`inherits_params`]).
1490///
1491/// An argument that no block documents is still reported by `R CMD check`
1492/// ("Undocumented arguments in Rd file"), so nothing goes missing silently.
1493/// See [`joins_author_topic`] for `tags` and `default_page`.
1494pub(crate) fn params_documented_elsewhere(tags: &[String], default_page: Option<&str>) -> bool {
1495    joins_author_topic(tags, default_page) || inherits_params(tags)
1496}
1497
1498/// The `@order` tag that sorts a generated block after every block without
1499/// one, so the topic's own block claims a merged page regardless of R file
1500/// order (#1590).
1501///
1502/// roxygen2 gives a merged page the `\name` and `\title` of the first block
1503/// it reads, and every block contributes a `\name` (a `@describeIn` block
1504/// too, from its object), so dropping generated titles cannot fix the page
1505/// name. Blocks are read in `order_blocks()` order: every block of the
1506/// package sorted with `order(as.double(<@order>))`, `Inf` for a block
1507/// without `@order`, which is stable and puts `NaN` last. A generated block
1508/// that joins an author topic therefore sorts after the author's block
1509/// wherever the R files sit, and the generated blocks of one topic keep their
1510/// source order among themselves (a topic made only of generated blocks
1511/// renders as before). On a merged page a later block's `@param` wins, which
1512/// is why joining blocks also drop their generated `@param` lines.
1513pub(crate) const ORDER_AFTER_TOPIC_BLOCKS: &str = "@order NaN";
1514
1515/// Whether a block with the author's `tags` gets `@order NaN`
1516/// ([`ORDER_AFTER_TOPIC_BLOCKS`]): it joins an author topic
1517/// ([`joins_author_topic`] against `default_page`), the author did not order
1518/// it with their own `@order`, and it renders a page (no `@noRd`).
1519pub(crate) fn orders_after_topic_blocks(tags: &[String], default_page: &str) -> bool {
1520    joins_author_topic(tags, Some(default_page))
1521        && !has_roxygen_tag(tags, "order")
1522        && !has_roxygen_tag(tags, "noRd")
1523}
1524
1525/// Push `#' @order NaN` ([`ORDER_AFTER_TOPIC_BLOCKS`]) when
1526/// [`orders_after_topic_blocks`] holds.
1527pub(crate) fn push_order_after_topic_blocks(
1528    lines: &mut Vec<String>,
1529    tags: &[String],
1530    default_page: &str,
1531) {
1532    if orders_after_topic_blocks(tags, default_page) {
1533        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' {0}",
                ORDER_AFTER_TOPIC_BLOCKS))
    })format!("#' {ORDER_AFTER_TOPIC_BLOCKS}"));
1534    }
1535}
1536
1537/// Prefix of a generated `@param` line in a standalone function's wrapper
1538/// (`#' .__MX_PARAM_FILLER__ @param x (no documentation available)`), for the
1539/// wrapper registry to resolve when it writes the file
1540/// (`resolve_standalone_pages` in `miniextendr-api/src/registry.rs`, which
1541/// spells the same prefix). Whether an author `@rdname topic` names the
1542/// function's own page or its file's stem page (the opt-in group page,
1543/// #1289), and whether another function on the page documents the argument,
1544/// is only known then. The registry keeps the line (without the prefix) when
1545/// the function stays on its own or file-stem page and no other function
1546/// there documents the argument, and drops it otherwise. roxygen2 keeps the later of two `@param` lines for one name, so
1547/// a filler written after the real description would replace it.
1548pub(crate) const PARAM_FILLER_MARKER: &str = ".__MX_PARAM_FILLER__ ";
1549
1550/// Append the generated `@param` tags for a standalone function's R
1551/// parameters to the author's `tags`, and return the `(doc_placeholder,
1552/// rust_param)` pair of every match_arg parameter that received the
1553/// write-time placeholder (the resolver registry needs one
1554/// `MX_MATCH_ARG_PARAM_DOCS` entry each).
1555///
1556/// Every non-dots parameter the author left undocumented gets, in order of
1557/// preference:
1558///
1559/// 1. `choices(...)`: the quoted list, "One of ..." / "One or more of ...";
1560/// 2. `match_arg`: a placeholder the wrapper writer resolves (#210);
1561/// 3. a `Missing<..>` parameter with a default:
1562///    [`MISSING_DEFAULT_PARAM_DOC`](crate::miniextendr_fn::MISSING_DEFAULT_PARAM_DOC)
1563///    (#2161);
1564/// 4. anything else: `(no documentation available)`.
1565///
1566/// Each line carries [`PARAM_FILLER_MARKER`], so the wrapper registry decides
1567/// whether it is written. Nothing is generated when the block takes its
1568/// arguments from elsewhere whatever page it sits on: `@describeIn` (the
1569/// destination documents them) or inherited params ([`inherits_params`]).
1570///
1571/// `call_param_doc` is the text for the trailing `.call` formal of a
1572/// `call = caller` or `call_arg` wrapper ([`CallAttribution::param_doc`],
1573/// #1613, #1834), appended after the parameters' lines unless the author
1574/// documented `.call`. It is no filler: the line is written as the author's
1575/// would be, under `@describeIn` and inherited params too. The formal is
1576/// generated, so the topic a block joins, or the topic it inherits from, has
1577/// it documented only when another generated block on that page does, and
1578/// roxygen2 merges equal `@param .call` lines of one page into one entry.
1579///
1580/// [`CallAttribution::param_doc`]: crate::r_wrapper_builder::CallAttribution::param_doc
1581pub(crate) fn push_fn_param_tags(
1582    tags: &mut Vec<String>,
1583    inputs: &syn::punctuated::Punctuated<syn::FnArg, ::syn::token::Commasyn::Token![,]>,
1584    parsed: &crate::miniextendr_fn::MiniextendrFunctionParsed,
1585    c_ident: &str,
1586    call_param_doc: Option<&str>,
1587) -> Vec<(String, String)> {
1588    let mut match_arg_doc_placeholders = Vec::new();
1589    let missing_default_docs = parsed.missing_default_param_docs();
1590    let call_line = call_param_doc
1591        .filter(|_| !param_documented(tags, ".call"))
1592        .map(|doc| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@param .call {0}", doc))
    })format!("@param .call {doc}"));
1593    if describe_in_topic(tags).is_some() || inherits_params(tags) {
1594        tags.extend(call_line);
1595        return match_arg_doc_placeholders;
1596    }
1597    let formals = parsed.formals();
1598    for arg in inputs {
1599        let syn::FnArg::Typed(pt) = arg else {
1600            continue;
1601        };
1602        let syn::Pat::Ident(pat_ident) = pt.pat.as_ref() else {
1603            continue;
1604        };
1605        // `NArgs` is no R formal (#1860), so it gets no `@param`.
1606        if parsed.is_dots_param(&pat_ident.ident) || crate::type_inspect::is_nargs_marker(&pt.ty) {
1607            continue;
1608        }
1609        let rust_name = crate::naming::ident_name(&pat_ident.ident);
1610        // The R formal, a declared `formal` included (#1842): the author's own
1611        // `@param conf.int` documents `conf_int: f64` declared as `conf.int`.
1612        let r_name = formals.of(&rust_name);
1613        if param_documented(tags, &r_name) {
1614            continue;
1615        }
1616
1617        if let Some(doc) = parsed
1618            .param_attrs(&rust_name)
1619            .and_then(crate::miniextendr_fn::ParamAttrs::literal_choices_doc)
1620        {
1621            tags.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}@param {1} {2}",
                PARAM_FILLER_MARKER, r_name, doc))
    })format!("{PARAM_FILLER_MARKER}@param {r_name} {doc}"));
1622        } else if parsed.has_match_arg_attr(&rust_name) {
1623            let doc_placeholder = crate::match_arg_keys::param_doc_placeholder(
1624                c_ident,
1625                &crate::match_arg_keys::param_key(&rust_name),
1626            );
1627            tags.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}@param {1} {2}",
                PARAM_FILLER_MARKER, r_name, doc_placeholder))
    })format!(
1628                "{PARAM_FILLER_MARKER}@param {r_name} {doc_placeholder}"
1629            ));
1630            match_arg_doc_placeholders.push((doc_placeholder, rust_name));
1631        } else if let Some(doc) = missing_default_docs.get(&r_name) {
1632            tags.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}@param {1} {2}",
                PARAM_FILLER_MARKER, r_name, doc))
    })format!("{PARAM_FILLER_MARKER}@param {r_name} {doc}"));
1633        } else {
1634            tags.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}@param {1} (no documentation available)",
                PARAM_FILLER_MARKER, r_name))
    })format!(
1635                "{PARAM_FILLER_MARKER}@param {r_name} (no documentation available)"
1636            ));
1637        }
1638    }
1639    tags.extend(call_line);
1640    match_arg_doc_placeholders
1641}
1642
1643// endregion
1644
1645/// Split an R formals/argument string on **top-level** commas only.
1646///
1647/// Commas nested inside parentheses, brackets, or braces — or inside a single-
1648/// or double-quoted string literal — are ignored. So `x, mode = c("a", "b"), ...`
1649/// yields `["x", "mode = c(\"a\", \"b\")", "..."]`, whereas a naive
1650/// `split(", ")` wrongly breaks the `c("a", "b")` default into two bogus
1651/// formals (`mode = c("a"` and `"b")`) — the source of spurious `@param "b")`
1652/// roxygen entries on match_arg'd trait-method shortcuts (ScalerS7 / ScalerR6).
1653pub(crate) fn split_r_formals(formals: &str) -> Vec<&str> {
1654    let bytes = formals.as_bytes();
1655    let mut out = Vec::new();
1656    let mut depth: i32 = 0;
1657    let mut quote: Option<u8> = None;
1658    let mut start = 0usize;
1659    let mut i = 0usize;
1660    while i < bytes.len() {
1661        let b = bytes[i];
1662        match quote {
1663            Some(q) => {
1664                if b == b'\\' {
1665                    i += 1; // skip the escaped char
1666                } else if b == q {
1667                    quote = None;
1668                }
1669            }
1670            None => match b {
1671                b'"' | b'\'' => quote = Some(b),
1672                b'(' | b'[' | b'{' => depth += 1,
1673                b')' | b']' | b'}' => depth -= 1,
1674                b',' if depth == 0 => {
1675                    out.push(formals[start..i].trim());
1676                    start = i + 1;
1677                }
1678                _ => {}
1679            },
1680        }
1681        i += 1;
1682    }
1683    out.push(formals[start..].trim());
1684    out.into_iter().filter(|s| !s.is_empty()).collect()
1685}
1686
1687/// Extract the R parameter name from a single formal (e.g. `mode = c("a","b")`
1688/// → `mode`). Pair with [`split_r_formals`], never a raw `split(',')`.
1689pub(crate) fn formal_name(formal: &str) -> &str {
1690    formal.split('=').next().unwrap_or(formal).trim()
1691}
1692
1693/// Like [`strip_method_tags`] but for R6 impl blocks.
1694///
1695/// R6 class-level `@param` tags are **kept** (roxygen2 8.0.0 inherits them into
1696/// all methods automatically — rd-R6.Rmd §"Class-level docs"). All other
1697/// method-only tags (`@return`, `@returns`, `@examples`, `@export`) are still
1698/// stripped with a compile-time warning. No warning is generated for `@param`.
1699///
1700/// Returns `(filtered_tags, warnings)` — same shape as [`strip_method_tags`].
1701/// Each stripped tag's warning is wrapped in its own anonymous
1702/// `const _: () = { ... };` block, so no per-impl-block disambiguator is
1703/// needed to keep warning-const names unique (#1118) — see
1704/// [`strip_method_tags`] for the full rationale.
1705pub(crate) fn strip_method_tags_r6(
1706    tags: &[String],
1707    type_name: &str,
1708    span: proc_macro2::Span,
1709) -> (Vec<String>, proc_macro2::TokenStream) {
1710    strip_tags_in(tags, type_name, span, METHOD_ONLY_TAGS_R6)
1711}
1712
1713/// Strip roxygen tag lines from doc attributes, keeping only regular documentation.
1714///
1715/// Returns a new vector of attributes with roxygen lines removed from doc comments.
1716/// Non-doc attributes, and doc attributes that are not string literals
1717/// (`#[doc = include_str!(..)]`), are passed through unchanged.
1718///
1719/// # Algorithm
1720///
1721/// The lines are classified by the same rule the tag extractor reads
1722/// ([`classify`]), so text that reaches R help leaves rustdoc and text that
1723/// does not stays:
1724///
1725/// - leading prose (before the first `@tag`) is kept;
1726/// - a tag and every line it takes are dropped: the paragraphs of a
1727///   multi-line tag (`@return X` / blank / more text), the wrapped lines of a
1728///   joined tag (`@title`), a bare `@rdname`'s topic line;
1729/// - a line after a single-line tag (`@export`, `@noRd`, `@rdname topic`) is
1730///   rustdoc only and kept, with the blank line before it.
1731///
1732/// A doc attribute is dropped when its first non-blank line is a tag or tag
1733/// text, or when it is blank and inside or at the end of a tag.
1734pub(crate) fn strip_roxygen_from_attrs(attrs: &[syn::Attribute]) -> Vec<syn::Attribute> {
1735    let lines = doc_lines(attrs);
1736    let roles = classify(&lines);
1737
1738    // Each attribute's lines are contiguous; its first non-blank line decides
1739    // (its first line when all are blank).
1740    let mut roxygen_attrs = HashSet::new();
1741    let mut offset = 0;
1742    for group in lines.chunk_by(|a, b| a.attr == b.attr) {
1743        let deciding = group.iter().position(|l| !l.text.is_empty()).unwrap_or(0);
1744        if #[allow(non_exhaustive_omitted_patterns)] match roles[offset + deciding] {
    LineRole::TagStart | LineRole::TagBody => true,
    _ => false,
}matches!(
1745            roles[offset + deciding],
1746            LineRole::TagStart | LineRole::TagBody
1747        ) {
1748            roxygen_attrs.insert(group[0].attr);
1749        }
1750        offset += group.len();
1751    }
1752
1753    attrs
1754        .iter()
1755        .enumerate()
1756        .filter(|(i, _)| !roxygen_attrs.contains(i))
1757        .map(|(_, attr)| attr.clone())
1758        .collect()
1759}
1760
1761#[cfg(test)]
1762mod tests;