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//! ```
5960use std::collections::HashSet;
6162/// 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];
9899/// 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];
111112/// 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"];
117118/// 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}
127128/// 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"
131let tag_name = tag132 .strip_prefix('@')
133 .and_then(|rest| rest.split_whitespace().next())
134 .unwrap_or("");
135MULTILINE_TAGS.contains(&tag_name)
136}
137138/// Check if a tag's continuation lines are joined with a space (see [`JOINED_TAGS`]).
139fn is_joined_tag(tag: &str) -> bool {
140let tag_name = tag141 .strip_prefix('@')
142 .and_then(|rest| rest.split_whitespace().next())
143 .unwrap_or("");
144JOINED_TAGS.contains(&tag_name)
145}
146147// region: doc lines
148149/// 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.
153attr: usize,
154/// The line with its rustdoc lead removed (see [`doc_lines`]); empty for
155 /// a blank line.
156text: String,
157}
158159/// 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> {
172let mut out = Vec::new();
173for (attr, a) in attrs.iter().enumerate() {
174if !a.path().is_ident("doc") {
175continue;
176 }
177let syn::Meta::NameValue(nv) = &a.meta else {
178continue;
179 };
180let syn::Expr::Lit(expr_lit) = &nv.value else {
181continue;
182 };
183let syn::Lit::Str(lit) = &expr_lit.lit else {
184continue;
185 };
186let value = lit.value();
187// `"".lines()` yields nothing, but an empty `///` is one blank line.
188let 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 };
193let lead = if value.contains('\n') {
194 common_indent(&lines)
195 } else {
196" "
197};
198for line in lines {
199let 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 }
210out211}
212213/// The leading whitespace every non-blank line of `lines` starts with.
214fn common_indent<'a>(lines: &[&'a str]) -> &'a str {
215lines216 .iter()
217 .filter(|line| !line.trim().is_empty())
218 .map(|line| &line[..line.len() - line.trim_start().len()])
219 .reduce(|common, lead| {
220let shared = common221 .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}
230231/// 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).
236Prose,
237/// A line starting with `@` (after leading whitespace): a new tag.
238TagStart,
239/// Part of the open tag's text, or a blank line that ends a tag (trailing,
240 /// or before the next tag).
241TagBody,
242/// After a single-line tag: rustdoc only, never R. A blank line is
243 /// rustdoc when a rustdoc-only line follows it.
244Rustdoc,
245}
246247/// 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.
251Multi,
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`.
255Joined { bare_topic: bool },
256/// Any other tag: its own line only.
257Single,
258}
259260impl OpenTag {
261fn of(tag_line: &str) -> Self {
262if is_multiline_tag(tag_line) {
263Self::Multi264 } else if is_joined_tag(tag_line) {
265Self::Joined { bare_topic: false }
266 } else if is_bare_topic_tag(tag_line) {
267Self::Joined { bare_topic: true }
268 } else {
269Self::Single270 }
271 }
272}
273274/// 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> {
277let mut roles = Vec::with_capacity(lines.len());
278let mut open: Option<OpenTag> = None;
279for line in lines {
280let text = line.text.trim_start();
281let role = if text.starts_with('@') {
282 open = Some(OpenTag::of(text));
283 LineRole::TagStart
284 } else {
285match open {
286None => LineRole::Prose,
287Some(OpenTag::Multi | OpenTag::Joined { bare_topic: false }) => LineRole::TagBody,
288Some(OpenTag::Joined { bare_topic: true }) => {
289if !text.is_empty() {
290 open = Some(OpenTag::Single);
291 }
292 LineRole::TagBody
293 }
294Some(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.
301let mut next_is_rustdoc = false;
302for (line, role) in lines.iter().zip(roles.iter_mut()).rev() {
303if !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 }
309roles310}
311312// endregion
313314/// 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.
325Rendered,
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`]).
329Suppressed,
330}
331332impl RdPage {
333/// [`RdPage::Suppressed`] when `no_page`, else [`RdPage::Rendered`].
334pub(crate) fn suppressed_if(no_page: bool) -> Self {
335if no_page {
336Self::Suppressed337 } else {
338Self::Rendered339 }
340 }
341}
342343/// 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> {
353roxygen_tags_from_attrs_impl(attrs, page)
354}
355356/// 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> {
365roxygen_tags_from_attrs_impl(attrs, page)
366}
367368/// 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 {
372has_roxygen_tag(&explicit_roxygen_tags_from_attrs(attrs), tag)
373}
374375/// 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> {
390roxygen_tags_with(attrs, crate::crate_config::roxygen_prose_links(), page)
391}
392393/// [`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> {
401let explicit = explicit_roxygen_tags_from_attrs(attrs);
402let no_page = page == RdPage::Suppressed || has_roxygen_tag(&explicit, "noRd");
403let (links, tag_links) = if no_page {
404 (ProseLinks::Strip, Neutralize::AllLinks)
405 } else {
406 (links, Neutralize::RustdocOnly)
407 };
408let mut tags: Vec<String> = explicit409 .into_iter()
410 .map(|tag| neutralize_tag_links(tag, tag_links))
411 .collect();
412413// Check which tags are present
414let tag_names_set = tag_names(&tags);
415let has_description = tag_names_set.contains("description");
416417// 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.
431if !has_description && let Some(desc) = leading_prose_from_attrs(attrs, links) {
432tags.insert(0, ::alloc::__export::must_use({
::alloc::fmt::format(format_args!("@description {0}", desc))
})format!("@description {}", desc));
433 }
434435tags436}
437438/// 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> {
452let lines = doc_lines(attrs);
453let roles = classify(&lines);
454let 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.
457let mut pending_blanks = 0;
458459for (line, role) in lines.iter().zip(roles) {
460match role {
461 LineRole::TagStart => {
462 pending_blanks = 0;
463 tags.push(line.text.trim_start().to_string());
464 }
465 LineRole::TagBody => {
466let Some(last) = tags.last_mut() else {
467continue;
468 };
469if is_multiline_tag(last) {
470if line.text.is_empty() {
471 pending_blanks += 1;
472 } else {
473for _ 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.
481last.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.
488LineRole::Prose | LineRole::Rustdoc => {}
489 }
490 }
491492tags493}
494495/// 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 {
500if 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}
506507/// 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 {
512let mut out = String::new();
513for line in tags.iter().flat_map(|tag| tag.lines()) {
514 out.push_str(&roxygen_line(line));
515 out.push('\n');
516 }
517out518}
519520/// 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]) {
525lines.extend(tags.iter().flat_map(|tag| tag.lines()).map(roxygen_line));
526}
527528/// Like [`push_roxygen_tags`] but takes `&[&str]` for filtered tag slices.
529pub(crate) fn push_roxygen_tags_str(lines: &mut Vec<String>, tags: &[&str]) {
530lines.extend(tags.iter().flat_map(|tag| tag.lines()).map(roxygen_line));
531}
532533/// 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 {
540if tag.contains(' ') {
541// Multi-word tag: match the full content after @
542tags.iter().any(|t| {
543t.trim_start()
544 .strip_prefix('@')
545 .is_some_and(|rest| rest.trim() == tag)
546 })
547 } else {
548tag_names(tags).contains(tag)
549 }
550}
551552/// 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 {
558let names = tag_names(tags);
559names.contains("export") || names.contains("exportS3Method")
560}
561562/// 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> {
569tags.iter().find_map(|t| {
570let rest = t.trim_start().strip_prefix("@rdname")?;
571let value = rest.trim();
572 (rest.starts_with(char::is_whitespace) && !value.is_empty()).then_some(value)
573 })
574}
575576/// 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) {
580let topic = method_page(tags, default);
581lines.push(::alloc::__export::must_use({
::alloc::fmt::format(format_args!("#\' @rdname {0}", topic))
})format!("#' @rdname {topic}"));
582}
583584/// 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> {
592source_tag_with(crate::crate_config::source_tags_enabled(), text.as_ref())
593}
594595/// [`source_tag`] with the crate setting passed in (testable without a manifest).
596pub(crate) fn source_tag_with(enabled: bool, text: &str) -> Option<String> {
597enabled.then(|| ::alloc::__export::must_use({
::alloc::fmt::format(format_args!("#\' @source {0}", text))
})format!("#' @source {text}"))
598}
599600/// 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> {
606source_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}
611612/// 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> {
615source_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}
620621/// 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> {
626let mut names = HashSet::new();
627for tag in tags {
628let trimmed = tag.trim_start();
629let name = trimmed
630 .strip_prefix('@')
631 .and_then(|rest| rest.split_whitespace().next());
632if let Some(name) = name {
633 names.insert(name);
634 }
635 }
636names637}
638639/// 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> {
644for tag in tags {
645let trimmed = tag.trim_start();
646if let Some(rest) = trimmed.strip_prefix('@') {
647let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
648if let Some(name) = parts.next()
649 && name == tag_name
650 {
651// Get the value (everything after the tag name)
652return parts.next().map(|s| s.trim());
653 }
654 }
655 }
656None657}
658659/// 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 {
666let lower = s.to_lowercase();
667let mut result = String::new();
668for word in lower.split_whitespace() {
669if !result.is_empty() {
670 result.push(' ');
671 }
672 result.push_str(word);
673 }
674result.truncate(
675result676 .trim_end_matches(|c: char| c.is_ascii_punctuation())
677 .len(),
678 );
679result680}
681682/// 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> {
687let mut lines = Vec::new();
688689for attr in attrs {
690if !attr.path().is_ident("doc") {
691continue;
692 }
693let syn::Meta::NameValue(nv) = &attr.meta else {
694continue;
695 };
696let syn::Expr::Lit(expr_lit) = &nv.value else {
697continue;
698 };
699let syn::Lit::Str(lit) = &expr_lit.lit else {
700continue;
701 };
702703let content = lit.value();
704let trimmed = content.trim();
705706// If we hit a @tag before any content, there's no implicit title
707if trimmed.starts_with('@') {
708if lines.is_empty() {
709return None;
710 }
711break;
712 }
713714// Empty line ends first sentence for title extraction
715if trimmed.is_empty() {
716break;
717 }
718719// Check if this line contains a sentence-ending period
720if let Some(pos) = trimmed.find(". ") {
721 lines.push(trimmed[..pos].to_string());
722break;
723 } else if trimmed.ends_with('.') {
724 lines.push(trimmed.trim_end_matches('.').to_string());
725break;
726 } else {
727 lines.push(trimmed.to_string());
728 }
729 }
730731if lines.is_empty() {
732None733 } else {
734Some(lines.join(" "))
735 }
736}
737738/// 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> {
748let mut lines = Vec::new();
749let mut found_first_paragraph = false;
750let mut in_gap = false;
751752for attr in attrs {
753if !attr.path().is_ident("doc") {
754continue;
755 }
756let syn::Meta::NameValue(nv) = &attr.meta else {
757continue;
758 };
759let syn::Expr::Lit(expr_lit) = &nv.value else {
760continue;
761 };
762let syn::Lit::Str(lit) = &expr_lit.lit else {
763continue;
764 };
765766let content = lit.value();
767let trimmed = content.trim();
768769// If we hit a @tag, stop
770if trimmed.starts_with('@') {
771break;
772 }
773774if !found_first_paragraph {
775// Still in the first paragraph (title)
776if trimmed.is_empty() {
777// Blank line — first paragraph ended, now in the gap
778found_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
784if !trimmed.is_empty() {
785// Start of second paragraph
786in_gap = false;
787 lines.push(trimmed.to_string());
788 }
789 } else {
790// In second paragraph
791if trimmed.is_empty() {
792// End of second paragraph
793break;
794 }
795 lines.push(trimmed.to_string());
796 }
797 }
798799if lines.is_empty() {
800None801 } else {
802Some(lines.join(" "))
803 }
804}
805806/// 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]
822Strip,
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`]).
827Keep,
828}
829830impl ProseLinks {
831/// Parse the manifest spelling (`"strip"` / `"keep"`).
832pub(crate) fn parse_name(name: &str) -> Option<Self> {
833match name {
834"strip" => Some(Self::Strip),
835"keep" => Some(Self::Keep),
836_ => None,
837 }
838 }
839}
840841/// 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> {
857let lines = doc_lines(attrs);
858let prose: Vec<&str> = lines859 .iter()
860 .zip(classify(&lines))
861 .take_while(|(_, role)| *role == LineRole::Prose)
862 .map(|(line, _)| line.text.as_str())
863 .collect();
864let first = prose.iter().position(|line| !line.is_empty())?;
865let last = prose.iter().rposition(|line| !line.is_empty())?;
866let prose = &prose[first..=last];
867Some(match links {
868 ProseLinks::Strip => sanitize_prose_links(prose, Neutralize::AllLinks),
869 ProseLinks::Keep => sanitize_prose_links(prose, Neutralize::RustdocOnly),
870 })
871}
872873/// 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 {
878if roxygen_tag_name(&tag).is_some_and(|name| CODE_TAGS.contains(&name)) {
879return tag;
880 }
881sanitize_prose_links(&tag.lines().collect::<Vec<_>>(), links)
882}
883884/// 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`]).
889AllLinks,
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.
894RustdocOnly,
895}
896897/// [`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 {
906fn flush(paragraph: &mut Vec<&str>, out: &mut Vec<String>, links: Neutralize) {
907if !paragraph.is_empty() {
908out.push(sanitize_roxygen_links(¶graph.join("\n"), links));
909paragraph.clear();
910 }
911 }
912913let mut out: Vec<String> = Vec::new();
914let mut paragraph: Vec<&str> = Vec::new();
915let mut open_fence: Option<&str> = None;
916for &line in lines {
917if let Some(open) = open_fence {
918if 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 }
933flush(&mut paragraph, &mut out, links);
934out.join("\n")
935}
936937/// 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> {
940let body = line.trim_start_matches(' ');
941if line.len() - body.len() > 3 {
942return None;
943 }
944let fence_char = body.chars().next().filter(|c| #[allow(non_exhaustive_omitted_patterns)] match c {
'`' | '~' => true,
_ => false,
}matches!(c, '`' | '~'))?;
945let run = body.len() - body.trim_start_matches(fence_char).len();
946 (run >= 3).then(|| &body[..run])
947}
948949/// 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 {
952close.starts_with(&open[..1])
953 && close.len() >= open.len()
954 && line.trim_start_matches(' ')[close.len()..]
955 .trim()
956 .is_empty()
957}
958959/// 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 {
977let bytes = s.as_bytes();
978let mut out = String::with_capacity(s.len());
979let mut i = 0;
980let mut in_code = false;
981while i < s.len() {
982if 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]`.
986in_code = !in_code;
987 out.push('`');
988 i += 1;
989continue;
990 }
991if !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.
995if i > 0 && bytes[i - 1] == b'\\' {
996 out.push('[');
997 i += 1;
998continue;
999 }
1000// `[` is ASCII, so `i + 1` is a char boundary.
1001if let Some(close_rel) = s[i + 1..].find(']') {
1002let close = i + 1 + close_rel;
1003let 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.
1009if bytes.get(close + 1) == Some(&b'(') {
1010if 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 }
1019continue;
1020 }
1021// `[text][target]` — reference link; `[text]` — shortcut link,
1022 // its own target.
1023let (target, end) = match bytes.get(close + 1) {
1024Some(b'[') => match s[close + 2..].find(']') {
1025Some(t_rel) => (&s[close + 2..close + 2 + t_rel], close + 2 + t_rel + 1),
1026None => (inner, close + 1),
1027 },
1028_ => (inner, close + 1),
1029 };
1030// Keep `text`, drop the brackets and any `[target]`.
1031if 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;
1037continue;
1038 }
1039 }
1040let ch = s[i..].chars().next().unwrap();
1041 out.push(ch);
1042 i += ch.len_utf8();
1043 }
1044out1045}
10461047/// 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.
1063let mut depth = 1usize;
1064let close = start1065 + s[start..].find(|c: char| {
1066match c {
1067'(' => depth += 1,
1068')' => depth -= 1,
1069_ => {}
1070 }
1071 depth == 0 || c.is_whitespace()
1072 })?;
1073if s.as_bytes()[close] != b')' {
1074return None;
1075 }
1076let dest = &s[start..close];
1077let path = strip_rustdoc_disambiguator(dest);
1078 (is_rust_path(path) && path.contains("::")).then_some(close + 1)
1079}
10801081/// `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 {
1085const 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 ];
1111match dest.split_once('@') {
1112Some((kind, rest)) if KINDS.contains(&kind) => rest,
1113_ => dest,
1114 }
1115}
11161117/// 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 {
1123let path = dest1124 .strip_suffix("()")
1125 .or_else(|| dest.strip_suffix('!'))
1126 .unwrap_or(dest);
1127path.split("::").all(|segment| {
1128let mut chars = segment.chars();
1129chars1130 .next()
1131 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1132 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1133 })
1134}
11351136/// 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 {
1149let target = target.trim();
1150let target = target1151 .strip_prefix('`')
1152 .and_then(|t| t.strip_suffix('`'))
1153 .unwrap_or(target);
1154let Some((pkg, _)) = target.rsplit_once("::") else {
1155return 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}
11591160/// 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 {
1172use quote::quote;
11731174// 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).
1177let tags = explicit_roxygen_tags_from_attrs(attrs);
1178let mut warnings = proc_macro2::TokenStream::new();
11791180// Check @title conflict
1181if 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 {
1185let 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 );
1191warnings.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! {
1192const _: () = {
1193#[deprecated(note = #msg)]
1194 #[doc(hidden)]
1195 #[allow(dead_code)]
1196const MINIEXTENDR_DOC_LINT_TITLE: () = ();
1197let _ = MINIEXTENDR_DOC_LINT_TITLE;
1198 };
1199 });
1200 }
12011202// Check @description conflict
1203if 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 {
1207let 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 );
1213warnings.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! {
1214const _: () = {
1215#[deprecated(note = #msg)]
1216 #[doc(hidden)]
1217 #[allow(dead_code)]
1218const MINIEXTENDR_DOC_LINT_DESC: () = ();
1219let _ = MINIEXTENDR_DOC_LINT_DESC;
1220 };
1221 });
1222 }
12231224warnings1225}
12261227/// 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}
12351236/// 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"];
12471248/// 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"];
12551256/// 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> {
1260let rest = tag.trim_start().strip_prefix('@')?;
1261let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
1262Some(&rest[..end])
1263}
12641265/// 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}
12771278/// 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) {
1303strip_tags_in(tags, type_name, span, METHOD_ONLY_TAGS)
1304}
13051306/// 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) {
1315use quote::quote_spanned;
13161317let mut filtered = Vec::new();
1318let mut warnings = proc_macro2::TokenStream::new();
13191320for tag in tags {
1321let Some(name) = roxygen_tag_name(tag) else {
1322 filtered.push(tag.clone());
1323continue;
1324 };
1325if !method_only.contains(&name) {
1326// Keeps unrecognised tags (and, for R6, @param) without warning.
1327filtered.push(tag.clone());
1328continue;
1329 }
1330let 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 =>
1337const _: () = {
1338#[deprecated(note = #msg)]
1339 #[doc(hidden)]
1340 #[allow(dead_code)]
1341const _MINIEXTENDR_IMPL_METHOD_TAG_WARN: () = ();
1342#[doc(hidden)]
1343 #[allow(dead_code)]
1344const _MINIEXTENDR_IMPL_METHOD_TAG_USE: () = _MINIEXTENDR_IMPL_METHOD_TAG_WARN;
1345 };
1346 });
1347 }
13481349 (filtered, warnings)
1350}
13511352/// 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> {
1365let mut names = HashSet::new();
1366for tag in tags {
1367let Some(names_token) = param_names_token(tag) else {
1368continue;
1369 };
1370for name in names_token.split(',') {
1371let name = name.trim();
1372if !name.is_empty() {
1373 names.insert(name.to_string());
1374 }
1375 }
1376 }
1377names1378}
13791380/// 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 {
1394tags.iter().any(|tag| tag_documents_param(tag, name))
1395}
13961397/// 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> {
1401tags.iter().find(|tag| tag_documents_param(tag, name))
1402}
14031404/// Shared predicate: does this single `@param` tag document `name`?
1405fn tag_documents_param(tag: &str, name: &str) -> bool {
1406let Some(names_token) = param_names_token(tag) else {
1407return false;
1408 };
1409names_token.split(',').any(|n| n.trim() == name)
1410}
14111412/// 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> {
1415let trimmed = tag.trim_start();
1416let rest = trimmed.strip_prefix("@param ")?;
1417rest.split_whitespace().next()
1418}
14191420// region: shared pages (#1590)
14211422/// 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> {
1425tags.iter().find_map(|t| {
1426let rest = t.trim_start().strip_prefix("@describeIn")?;
1427if rest.starts_with(char::is_whitespace) {
1428rest.split_whitespace().next()
1429 } else {
1430None1431 }
1432 })
1433}
14341435/// 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 {
1448describe_in_topic(tags).is_some()
1449 || rdname_value(tags).is_some_and(|topic| Some(topic) != default_page)
1450}
14511452/// 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 {
1458describe_in_topic(tags)
1459 .or_else(|| rdname_value(tags))
1460 .unwrap_or(default_page)
1461}
14621463/// 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 {
1469tags.iter().any(|tag| match roxygen_tag_name(tag) {
1470Some("inheritParams") => true,
1471Some("inherit") => {
1472let mut fields = tag.split_whitespace().skip(2).peekable(); // `@inherit`, source
1473fields.peek().is_none() || fields.any(|field| field == "params")
1474 }
1475_ => false,
1476 })
1477}
14781479/// 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 {
1495joins_author_topic(tags, default_page) || inherits_params(tags)
1496}
14971498/// 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";
15141515/// 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 {
1520joins_author_topic(tags, Some(default_page))
1521 && !has_roxygen_tag(tags, "order")
1522 && !has_roxygen_tag(tags, "noRd")
1523}
15241525/// 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) {
1532if orders_after_topic_blocks(tags, default_page) {
1533lines.push(::alloc::__export::must_use({
::alloc::fmt::format(format_args!("#\' {0}",
ORDER_AFTER_TOPIC_BLOCKS))
})format!("#' {ORDER_AFTER_TOPIC_BLOCKS}"));
1534 }
1535}
15361537/// 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__ ";
15491550/// 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)> {
1588let mut match_arg_doc_placeholders = Vec::new();
1589let missing_default_docs = parsed.missing_default_param_docs();
1590let call_line = call_param_doc1591 .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}"));
1593if describe_in_topic(tags).is_some() || inherits_params(tags) {
1594tags.extend(call_line);
1595return match_arg_doc_placeholders;
1596 }
1597let formals = parsed.formals();
1598for arg in inputs {
1599let syn::FnArg::Typed(pt) = arg else {
1600continue;
1601 };
1602let syn::Pat::Ident(pat_ident) = pt.pat.as_ref() else {
1603continue;
1604 };
1605// `NArgs` is no R formal (#1860), so it gets no `@param`.
1606if parsed.is_dots_param(&pat_ident.ident) || crate::type_inspect::is_nargs_marker(&pt.ty) {
1607continue;
1608 }
1609let 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`.
1612let r_name = formals.of(&rust_name);
1613if param_documented(tags, &r_name) {
1614continue;
1615 }
16161617if 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) {
1623let 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 }
1639tags.extend(call_line);
1640match_arg_doc_placeholders1641}
16421643// endregion
16441645/// 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> {
1654let bytes = formals.as_bytes();
1655let mut out = Vec::new();
1656let mut depth: i32 = 0;
1657let mut quote: Option<u8> = None;
1658let mut start = 0usize;
1659let mut i = 0usize;
1660while i < bytes.len() {
1661let b = bytes[i];
1662match quote {
1663Some(q) => {
1664if b == b'\\' {
1665 i += 1; // skip the escaped char
1666} else if b == q {
1667 quote = None;
1668 }
1669 }
1670None => match b {
1671b'"' | b'\'' => quote = Some(b),
1672b'(' | b'[' | b'{' => depth += 1,
1673b')' | b']' | b'}' => depth -= 1,
1674b',' if depth == 0 => {
1675 out.push(formals[start..i].trim());
1676 start = i + 1;
1677 }
1678_ => {}
1679 },
1680 }
1681 i += 1;
1682 }
1683out.push(formals[start..].trim());
1684out.into_iter().filter(|s| !s.is_empty()).collect()
1685}
16861687/// 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 {
1690formal.split('=').next().unwrap_or(formal).trim()
1691}
16921693/// 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) {
1710strip_tags_in(tags, type_name, span, METHOD_ONLY_TAGS_R6)
1711}
17121713/// 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> {
1735let lines = doc_lines(attrs);
1736let roles = classify(&lines);
17371738// Each attribute's lines are contiguous; its first non-blank line decides
1739 // (its first line when all are blank).
1740let mut roxygen_attrs = HashSet::new();
1741let mut offset = 0;
1742for group in lines.chunk_by(|a, b| a.attr == b.attr) {
1743let deciding = group.iter().position(|l| !l.text.is_empty()).unwrap_or(0);
1744if #[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 }
17521753attrs1754 .iter()
1755 .enumerate()
1756 .filter(|(i, _)| !roxygen_attrs.contains(i))
1757 .map(|(_, attr)| attr.clone())
1758 .collect()
1759}
17601761#[cfg(test)]
1762mod tests;