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//! # R Package Configuration
20//!
21//! For roxygen2 to process multiline tags correctly, add this to your `DESCRIPTION` file:
22//!
23//! ```text
24//! Roxygen: list(markdown = TRUE)
25//! ```
26
27use std::collections::HashSet;
28
29/// Tags that allow multi-line content (continuation lines appended).
30/// All other tags are treated as single-line.
31const MULTILINE_TAGS: &[&str] = &[
32 "examples",
33 "description",
34 "details",
35 "return",
36 "returns",
37 "param",
38 "note",
39 "seealso",
40 "section",
41 "format",
42 "references",
43 "slot",
44 "field",
45 "value", // synonym for return
46 "prop", // S7 property documentation (roxygen2 8.0.0+)
47];
48
49/// Check if a tag name supports multi-line content.
50fn is_multiline_tag(tag: &str) -> bool {
51 // Extract the tag name from "@tagname ..." or "@tagname"
52 let tag_name = tag
53 .strip_prefix('@')
54 .and_then(|rest| rest.split_whitespace().next())
55 .unwrap_or("");
56 MULTILINE_TAGS.contains(&tag_name)
57}
58
59/// Extract roxygen tag lines (starting with '@') from Rust doc attributes.
60///
61/// Most tags capture only a single line. Multi-line tags like `@examples`,
62/// `@description`, `@param`, and `@return` append continuation lines.
63///
64/// For R6 methods, if no explicit tags are found, the first doc comment paragraph
65/// is auto-converted to `@description`.
66pub(crate) fn roxygen_tags_from_attrs(attrs: &[syn::Attribute]) -> Vec<String> {
67 roxygen_tags_from_attrs_impl(attrs)
68}
69
70/// Extract roxygen tags for an impl-block method.
71///
72/// Identical to [`roxygen_tags_from_attrs`] — leading prose is promoted to
73/// `@description` in every context. Kept as a named alias so the class-system
74/// generators read clearly at the call site.
75pub(crate) fn roxygen_tags_from_attrs_for_r6_method(attrs: &[syn::Attribute]) -> Vec<String> {
76 roxygen_tags_from_attrs_impl(attrs)
77}
78
79/// Core implementation of roxygen tag extraction from `#[doc = "..."]` attributes.
80///
81/// Walks through doc attributes line by line. Lines starting with `@` begin a new tag.
82/// Continuation lines are appended only if the current tag is multiline-capable.
83///
84/// Before processing, the attribute slice is partitioned into doc and non-doc groups
85/// (stable order within each group). All doc attributes are processed first, so that
86/// interleaved `#[cfg(...)]` or other non-doc attributes never break multiline-tag
87/// continuation. This is a pure parse-side transform — the emitted `TokenStream` is
88/// unaffected; only the roxygen text assembly sees the normalised order.
89///
90/// Leading prose (paragraphs before the first `@tag`) is promoted to a
91/// `@description` tag — never `@title`. The `@title` is left to the caller
92/// (structural name); see [`leading_prose_from_attrs`] for why.
93fn roxygen_tags_from_attrs_impl(attrs: &[syn::Attribute]) -> Vec<String> {
94 let mut tags = explicit_roxygen_tags_from_attrs(attrs);
95
96 // Check which tags are present
97 let tag_names_set = tag_names(&tags);
98 let has_description = tag_names_set.contains("description");
99
100 // Promote leading prose doc comments to `@description` (all paragraphs before
101 // the first `@tag`), unless the author already wrote an explicit `@description`.
102 //
103 // The page `@title` is NOT synthesized from prose. Rustdoc summaries are markdown
104 // written for `cargo doc` — intra-doc links (`[`Foo`]`, `[x][crate::y]`) and code
105 // spans — which roxygen2's markdown parser tries to resolve as R `\link{}` topics
106 // and fails ("could not resolve link to topic" / "refers to un-installed package").
107 // Titles come from the structural name instead: the wrapper name for standalone
108 // functions (see `lib.rs`) and `@title {Name} Class` for class blocks (see
109 // `ClassDocBuilder`). Demoting prose to `@description` keeps it visible while the
110 // title stays link-free.
111 //
112 // `leading_prose_from_attrs` returns `None` for tag-led blocks (no leading prose),
113 // so `@inherit`/`@rdname`-only docs never gain a spurious description.
114 if !has_description && let Some(desc) = leading_prose_from_attrs(attrs) {
115 tags.insert(0, format!("@description {}", desc));
116 }
117
118 tags
119}
120
121/// Parse only the author-written `@tag` lines from doc attributes — no
122/// leading-prose promotion.
123///
124/// [`roxygen_tags_from_attrs_impl`] layers the leading-prose → `@description`
125/// promotion on top of this. [`doc_conflict_warnings`] must use this raw parse
126/// instead: comparing the *synthesized* description (all leading prose) against
127/// the implicit one (second paragraph) warned on every multi-paragraph doc
128/// comment that had no explicit `@description` at all (#1172).
129fn explicit_roxygen_tags_from_attrs(attrs: &[syn::Attribute]) -> Vec<String> {
130 let mut tags = Vec::new();
131
132 // Partition: doc attrs first (stable), non-doc attrs after.
133 // This means interleaved #[cfg(...)] and similar never interrupt doc processing.
134 let doc_attrs: Vec<&syn::Attribute> =
135 attrs.iter().filter(|a| a.path().is_ident("doc")).collect();
136
137 for attr in doc_attrs {
138 let syn::Meta::NameValue(nv) = &attr.meta else {
139 continue;
140 };
141 let syn::Expr::Lit(expr_lit) = &nv.value else {
142 continue;
143 };
144 let syn::Lit::Str(lit) = &expr_lit.lit else {
145 continue;
146 };
147 for line in lit.value().lines() {
148 let trimmed = line.trim_start();
149 if trimmed.starts_with('@') {
150 tags.push(trimmed.to_string());
151 } else if !trimmed.is_empty()
152 && let Some(last) = tags.last_mut()
153 && is_multiline_tag(last)
154 {
155 // Continuation line for the current multi-line tag.
156 last.push('\n');
157 last.push_str(trimmed);
158 }
159 // Leading prose (before any @tag) is captured separately by
160 // `leading_prose_from_attrs` and promoted to @description in
161 // `roxygen_tags_from_attrs_impl`.
162 }
163 }
164
165 tags
166}
167
168/// Render roxygen tag lines as "#' ..." comment lines.
169///
170/// Multiline tags (containing '\n') are split into separate `#'` lines.
171pub(crate) fn format_roxygen_tags(tags: &[String]) -> String {
172 if tags.is_empty() {
173 return String::new();
174 }
175 let mut out = String::new();
176 for tag in tags {
177 for line in tag.lines() {
178 out.push_str("#' ");
179 out.push_str(line);
180 out.push('\n');
181 }
182 }
183 out
184}
185
186/// Push roxygen tag lines into a vector of R wrapper lines.
187///
188/// Multiline tags (containing '\n') are split into separate `#'` lines.
189pub(crate) fn push_roxygen_tags(lines: &mut Vec<String>, tags: &[String]) {
190 for tag in tags {
191 for line in tag.lines() {
192 lines.push(format!("#' {}", line));
193 }
194 }
195}
196
197/// Like [`push_roxygen_tags`] but takes `&[&str]` for filtered tag slices.
198pub(crate) fn push_roxygen_tags_str(lines: &mut Vec<String>, tags: &[&str]) {
199 for tag in tags {
200 for line in tag.lines() {
201 lines.push(format!("#' {}", line));
202 }
203 }
204}
205
206/// Return true if the tag list contains a specific roxygen tag.
207///
208/// Supports both single-word tags (e.g., `"export"`, `"noRd"`) and
209/// multi-word tags (e.g., `"keywords internal"`). For single-word tags,
210/// matches the first word after `@`. For multi-word tags, matches the
211/// full content after `@` (trimmed).
212pub(crate) fn has_roxygen_tag(tags: &[String], tag: &str) -> bool {
213 if tag.contains(' ') {
214 // Multi-word tag: match the full content after @
215 tags.iter().any(|t| {
216 t.trim_start()
217 .strip_prefix('@')
218 .is_some_and(|rest| rest.trim() == tag)
219 })
220 } else {
221 tag_names(tags).contains(tag)
222 }
223}
224
225/// Build a roxygen `@source` traceability line for a class method.
226///
227/// Returns `"#' @source Generated by miniextendr from \`Type::method\`"`.
228/// Use this wherever a class-generator needs to emit a source-provenance
229/// comment linking the generated R wrapper back to the originating Rust
230/// `impl` block method.
231pub(crate) fn method_source_tag(type_ident: &syn::Ident, method_ident: &syn::Ident) -> String {
232 format!(
233 "#' @source Generated by miniextendr from `{}::{}`",
234 type_ident, method_ident
235 )
236}
237
238/// Build a roxygen `@source` traceability line for a class definition.
239///
240/// Returns ``"#' @source Generated by miniextendr from Rust type `Type`"``.
241/// Companion to [`method_source_tag`] for class-level documentation blocks.
242pub(crate) fn class_source_tag(type_ident: &syn::Ident) -> String {
243 format!(
244 "#' @source Generated by miniextendr from Rust type `{}`",
245 type_ident
246 )
247}
248
249/// Extract the set of tag names from a list of roxygen tag strings.
250///
251/// Each tag string is expected to start with `@tagname`. Returns a set of
252/// the tag names (without the `@` prefix).
253fn tag_names(tags: &[String]) -> HashSet<&str> {
254 let mut names = HashSet::new();
255 for tag in tags {
256 let trimmed = tag.trim_start();
257 let name = trimmed
258 .strip_prefix('@')
259 .and_then(|rest| rest.split_whitespace().next());
260 if let Some(name) = name {
261 names.insert(name);
262 }
263 }
264 names
265}
266
267/// Find the value of a specific roxygen tag (e.g., "title" for `@title ...`).
268///
269/// Returns `None` if the tag is not present or has no value.
270#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
271pub(crate) fn find_tag_value<'a>(tags: &'a [String], tag_name: &str) -> Option<&'a str> {
272 for tag in tags {
273 let trimmed = tag.trim_start();
274 if let Some(rest) = trimmed.strip_prefix('@') {
275 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
276 if let Some(name) = parts.next()
277 && name == tag_name
278 {
279 // Get the value (everything after the tag name)
280 return parts.next().map(|s| s.trim());
281 }
282 }
283 }
284 None
285}
286
287/// Normalize text for comparison: lowercase, collapse whitespace, strip trailing punctuation.
288///
289/// Used by `doc_conflict_warnings` to compare explicit `@title`/`@description` values
290/// with implicit values derived from the doc comment structure. Normalization ensures
291/// minor formatting differences (extra spaces, trailing periods) don't trigger false warnings.
292#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
293fn normalize_for_comparison(s: &str) -> String {
294 let lower = s.to_lowercase();
295 let mut result = String::new();
296 for word in lower.split_whitespace() {
297 if !result.is_empty() {
298 result.push(' ');
299 }
300 result.push_str(word);
301 }
302 result.truncate(
303 result
304 .trim_end_matches(|c: char| c.is_ascii_punctuation())
305 .len(),
306 );
307 result
308}
309
310/// Extract the implicit title from doc attributes (first sentence, up to first `.` or newline).
311///
312/// Returns `None` if there are no doc comments or if docs start with a `@tag`.
313#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
314pub(crate) fn implicit_title_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
315 let mut lines = Vec::new();
316
317 for attr in attrs {
318 if !attr.path().is_ident("doc") {
319 continue;
320 }
321 let syn::Meta::NameValue(nv) = &attr.meta else {
322 continue;
323 };
324 let syn::Expr::Lit(expr_lit) = &nv.value else {
325 continue;
326 };
327 let syn::Lit::Str(lit) = &expr_lit.lit else {
328 continue;
329 };
330
331 let content = lit.value();
332 let trimmed = content.trim();
333
334 // If we hit a @tag before any content, there's no implicit title
335 if trimmed.starts_with('@') {
336 if lines.is_empty() {
337 return None;
338 }
339 break;
340 }
341
342 // Empty line ends first sentence for title extraction
343 if trimmed.is_empty() {
344 break;
345 }
346
347 // Check if this line contains a sentence-ending period
348 if let Some(pos) = trimmed.find(". ") {
349 lines.push(trimmed[..pos].to_string());
350 break;
351 } else if trimmed.ends_with('.') {
352 lines.push(trimmed.trim_end_matches('.').to_string());
353 break;
354 } else {
355 lines.push(trimmed.to_string());
356 }
357 }
358
359 if lines.is_empty() {
360 None
361 } else {
362 Some(lines.join(" "))
363 }
364}
365
366/// Extract the implicit description from doc attributes (second paragraph).
367///
368/// In roxygen2, the first paragraph is the title and the second paragraph is the
369/// description. This function skips the first paragraph (up to the first blank line)
370/// and returns the second paragraph.
371///
372/// Returns `None` if there is no second paragraph, no doc comments, or if docs
373/// start with a `@tag`.
374#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
375pub(crate) fn implicit_description_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
376 let mut lines = Vec::new();
377 let mut found_first_paragraph = false;
378 let mut in_gap = false;
379
380 for attr in attrs {
381 if !attr.path().is_ident("doc") {
382 continue;
383 }
384 let syn::Meta::NameValue(nv) = &attr.meta else {
385 continue;
386 };
387 let syn::Expr::Lit(expr_lit) = &nv.value else {
388 continue;
389 };
390 let syn::Lit::Str(lit) = &expr_lit.lit else {
391 continue;
392 };
393
394 let content = lit.value();
395 let trimmed = content.trim();
396
397 // If we hit a @tag, stop
398 if trimmed.starts_with('@') {
399 break;
400 }
401
402 if !found_first_paragraph {
403 // Still in the first paragraph (title)
404 if trimmed.is_empty() {
405 // Blank line — first paragraph ended, now in the gap
406 found_first_paragraph = true;
407 in_gap = true;
408 }
409 // Non-empty lines before first blank are title — skip
410 } else if in_gap {
411 // Between paragraphs — skip blank lines
412 if !trimmed.is_empty() {
413 // Start of second paragraph
414 in_gap = false;
415 lines.push(trimmed.to_string());
416 }
417 } else {
418 // In second paragraph
419 if trimmed.is_empty() {
420 // End of second paragraph
421 break;
422 }
423 lines.push(trimmed.to_string());
424 }
425 }
426
427 if lines.is_empty() {
428 None
429 } else {
430 Some(lines.join(" "))
431 }
432}
433
434/// Collect the leading prose of a doc comment (all paragraphs before the first
435/// `@tag`) as roxygen `@description` text, with rustdoc intra-doc links neutralized.
436///
437/// Each `///` line is one doc attribute; a blank line is an empty attribute and marks
438/// a paragraph boundary. Paragraphs are joined with `"\n\n"` so `push_roxygen_tags`
439/// renders blank `#'` lines between them — roxygen2 multi-paragraph description text.
440///
441/// Returns `None` when the block has no leading prose (empty, or starts with a `@tag`),
442/// so tag-led blocks never gain a spurious `@description`.
443fn leading_prose_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
444 let mut paragraphs: Vec<Vec<String>> = Vec::new();
445 let mut current: Vec<String> = Vec::new();
446
447 for attr in attrs {
448 if !attr.path().is_ident("doc") {
449 continue;
450 }
451 let syn::Meta::NameValue(nv) = &attr.meta else {
452 continue;
453 };
454 let syn::Expr::Lit(expr_lit) = &nv.value else {
455 continue;
456 };
457 let syn::Lit::Str(lit) = &expr_lit.lit else {
458 continue;
459 };
460
461 let content = lit.value();
462 let trimmed = content.trim();
463
464 if trimmed.starts_with('@') {
465 // First tag ends the prose block.
466 break;
467 }
468 if trimmed.is_empty() {
469 // Blank line — paragraph boundary.
470 if !current.is_empty() {
471 paragraphs.push(std::mem::take(&mut current));
472 }
473 } else {
474 current.push(sanitize_roxygen_links(trimmed));
475 }
476 }
477 if !current.is_empty() {
478 paragraphs.push(current);
479 }
480
481 if paragraphs.is_empty() {
482 None
483 } else {
484 let joined: Vec<String> = paragraphs.into_iter().map(|p| p.join(" ")).collect();
485 Some(joined.join("\n\n"))
486 }
487}
488
489/// Neutralize rustdoc intra-doc link syntax so prose is valid roxygen2 markdown.
490///
491/// rustdoc `[`Foo`]` / `[Foo]` / `[text][target]` are intra-doc links resolved
492/// against *Rust* items by `cargo doc`. roxygen2 (markdown on) reads the same
493/// `[...]` as an R `\link{}` to a *help topic*, which can't resolve. We strip the
494/// link brackets down to the visible text (keeping any `` `code` `` span), while
495/// leaving genuine markdown links `[text](url)` — recognized by the `]( ` that
496/// follows — untouched.
497fn sanitize_roxygen_links(s: &str) -> String {
498 let bytes = s.as_bytes();
499 let mut out = String::with_capacity(s.len());
500 let mut i = 0;
501 let mut in_code = false;
502 while i < s.len() {
503 if bytes[i] == b'`' {
504 // Inline code span: markdown (and roxygen2) never parse `[...]`
505 // inside backticks as a link, so neither do we — stripping there
506 // would corrupt code like `x[i]`.
507 in_code = !in_code;
508 out.push('`');
509 i += 1;
510 continue;
511 }
512 if !in_code && bytes[i] == b'[' {
513 // `\[` is a backslash-escaped literal bracket (idiomatic rustdoc
514 // for suppressing intra-doc links, e.g. `Box<\[u8\]>`). Not a link
515 // opener — pass it through; roxygen2's markdown unescapes it.
516 if i > 0 && bytes[i - 1] == b'\\' {
517 out.push('[');
518 i += 1;
519 continue;
520 }
521 // `[` is ASCII, so `i + 1` is a char boundary.
522 if let Some(close_rel) = s[i + 1..].find(']') {
523 let close = i + 1 + close_rel;
524 let inner = &s[i + 1..close];
525 match bytes.get(close + 1) {
526 // `[text](url)` — real markdown link. Emit `[` literally and let
527 // the inner text + `](url)` flow through unchanged.
528 Some(b'(') => {
529 out.push('[');
530 i += 1;
531 continue;
532 }
533 // `[text][target]` — reference link. Keep `text`, drop `[target]`.
534 Some(b'[') => {
535 out.push_str(inner);
536 if let Some(t_rel) = s[close + 2..].find(']') {
537 i = close + 2 + t_rel + 1;
538 } else {
539 i = close + 1;
540 }
541 continue;
542 }
543 // `[text]` — shortcut link. Keep `text`, drop the brackets.
544 _ => {
545 out.push_str(inner);
546 i = close + 1;
547 continue;
548 }
549 }
550 }
551 }
552 let ch = s[i..].chars().next().unwrap();
553 out.push(ch);
554 i += ch.len_utf8();
555 }
556 out
557}
558
559/// Check for conflicts between explicit `@title`/`@description` tags and implicit values.
560///
561/// When the `doc-lint` feature is enabled, returns tokens that generate compile-time
562/// deprecation warnings if explicit roxygen tags differ from the implicit values
563/// derived from the doc comment structure.
564///
565/// The returned tokens should be appended to the macro expansion output.
566#[cfg(feature = "doc-lint")]
567pub(crate) fn doc_conflict_warnings(
568 attrs: &[syn::Attribute],
569 _span: proc_macro2::Span,
570) -> proc_macro2::TokenStream {
571 use quote::quote;
572
573 // Raw parse only: `roxygen_tags_from_attrs` synthesizes a `@description`
574 // from leading prose, which this lint must not mistake for an
575 // author-written tag (#1172).
576 let tags = explicit_roxygen_tags_from_attrs(attrs);
577 let mut warnings = proc_macro2::TokenStream::new();
578
579 // Check @title conflict
580 if let Some(explicit) = find_tag_value(&tags, "title")
581 && let Some(implicit) = implicit_title_from_attrs(attrs)
582 && normalize_for_comparison(explicit) != normalize_for_comparison(&implicit)
583 {
584 let msg = format!(
585 "miniextendr doc-lint: explicit @title differs from first doc line. \
586 R's roxygen2 uses the first line as the title. \
587 implicit: \"{}\", explicit @title: \"{}\"",
588 implicit, explicit
589 );
590 warnings.extend(quote! {
591 const _: () = {
592 #[deprecated(note = #msg)]
593 #[doc(hidden)]
594 #[allow(dead_code)]
595 const MINIEXTENDR_DOC_LINT_TITLE: () = ();
596 let _ = MINIEXTENDR_DOC_LINT_TITLE;
597 };
598 });
599 }
600
601 // Check @description conflict
602 if let Some(explicit) = find_tag_value(&tags, "description")
603 && let Some(implicit) = implicit_description_from_attrs(attrs)
604 && normalize_for_comparison(explicit) != normalize_for_comparison(&implicit)
605 {
606 let msg = format!(
607 "miniextendr doc-lint: explicit @description differs from first paragraph. \
608 R's roxygen2 uses the first paragraph as the description. \
609 implicit: \"{}\", explicit @description: \"{}\"",
610 implicit, explicit
611 );
612 warnings.extend(quote! {
613 const _: () = {
614 #[deprecated(note = #msg)]
615 #[doc(hidden)]
616 #[allow(dead_code)]
617 const MINIEXTENDR_DOC_LINT_DESC: () = ();
618 let _ = MINIEXTENDR_DOC_LINT_DESC;
619 };
620 });
621 }
622
623 warnings
624}
625
626/// No-op when doc-lint feature is disabled.
627#[cfg(not(feature = "doc-lint"))]
628pub(crate) fn doc_conflict_warnings(
629 _attrs: &[syn::Attribute],
630 _span: proc_macro2::Span,
631) -> proc_macro2::TokenStream {
632 proc_macro2::TokenStream::new()
633}
634
635/// Roxygen tags that only make sense on individual methods, not on impl blocks.
636///
637/// - `@param` — impl blocks have no parameters (except for R6 class-level param docs,
638/// which roxygen2 8.0.0 inherits into all methods; those are exempted by
639/// [`strip_method_tags_r6`]).
640/// - `@return` / `@returns` — impl blocks have no return value.
641/// - `@examples` — examples belong on the method that is being demonstrated.
642/// - `@export` — redundant: export for class-level docs is handled by
643/// `ClassDocBuilder`, which emits `@export` based on the impl block's
644/// `internal` / `noexport` attrs, not on user-supplied roxygen.
645const METHOD_ONLY_TAGS: &[&str] = &["param", "return", "returns", "examples", "export"];
646
647/// Tags stripped from impl-block docs for R6 classes — same as `METHOD_ONLY_TAGS`
648/// minus `"param"`, since roxygen2 8.0.0 inherits class-level `@param` tags into
649/// all R6 methods and strips them from the rendered method entries automatically.
650/// This means `/// @param breed …` on an R6 impl block is valid and intentional,
651/// not a misplaced method-only tag. Keeping them avoids both the compile warning
652/// and the resulting `(no documentation available)` placeholder on subclass ctors.
653const METHOD_ONLY_TAGS_R6: &[&str] = &["return", "returns", "examples", "export"];
654
655/// Extract the tag name from a roxygen line (everything between `@` and the
656/// first whitespace character). Returns `None` for lines that don't start with
657/// a tag.
658fn roxygen_tag_name(tag: &str) -> Option<&str> {
659 let rest = tag.trim_start().strip_prefix('@')?;
660 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
661 Some(&rest[..end])
662}
663
664/// Monotonic per-crate counter used to disambiguate the compile-warning const
665/// names emitted by [`strip_method_tags`] / [`strip_method_tags_r6`].
666///
667/// The const name is keyed on the type name, but two `#[miniextendr] impl Foo`
668/// blocks on the *same* type (e.g. an inherent impl plus a trait impl, or two
669/// inherent impls) each restart their per-block `warning_id` at 0, so without a
670/// per-block disambiguator they emit identically-named consts and collide with
671/// `error[E0428]: the name ... is defined multiple times` (#1118). Every call
672/// site draws a fresh block id from this counter so the names stay unique
673/// within a crate compilation — the exact scope const names must be unique in,
674/// since the statics reset per rustc process (one process per crate).
675pub(crate) fn next_impl_tag_block_id() -> usize {
676 use std::sync::atomic::{AtomicUsize, Ordering};
677 static IMPL_TAG_WARN_BLOCK: AtomicUsize = AtomicUsize::new(0);
678 IMPL_TAG_WARN_BLOCK.fetch_add(1, Ordering::Relaxed)
679}
680
681/// Filter out method-specific roxygen tags from impl-block-level docs and emit
682/// compile warnings for each stripped tag.
683///
684/// Method-specific tags (`@param`, `@return`, `@returns`, `@examples`,
685/// `@export`) on an impl block are meaningless — they belong on individual
686/// methods, or (for `@export`) are emitted by `ClassDocBuilder`. When users
687/// put them on impl blocks, the tags leak into the class-level Rd file where
688/// R CMD check warns about "documented arguments not in \\usage" and similar.
689///
690/// `block_id` is a per-impl-block disambiguator (see [`next_impl_tag_block_id`])
691/// that keeps the emitted warning-const names unique across multiple impl
692/// blocks on the same type (#1118).
693///
694/// Returns the filtered tags and a TokenStream of deprecation warnings for
695/// each stripped tag. The caller should append the warnings to its output so
696/// the user sees them at compile time.
697pub(crate) fn strip_method_tags(
698 tags: &[String],
699 type_name: &str,
700 block_id: usize,
701 span: proc_macro2::Span,
702) -> (Vec<String>, proc_macro2::TokenStream) {
703 use quote::quote_spanned;
704
705 let mut filtered = Vec::new();
706 let mut warnings = proc_macro2::TokenStream::new();
707 let mut warning_id: usize = 0;
708
709 for tag in tags {
710 let Some(name) = roxygen_tag_name(tag) else {
711 filtered.push(tag.clone());
712 continue;
713 };
714 if !METHOD_ONLY_TAGS.contains(&name) {
715 filtered.push(tag.clone());
716 continue;
717 }
718 let msg = format!(
719 "miniextendr: @{} on impl block `{}` has no effect — move it to the method. Tag: {}",
720 name,
721 type_name,
722 tag.trim()
723 );
724 let suffix = format!(
725 "{}_{}_{}",
726 type_name.replace(|c: char| !c.is_alphanumeric(), "_"),
727 block_id,
728 warning_id
729 );
730 let ident = quote::format_ident!("_MINIEXTENDR_IMPL_METHOD_TAG_WARN_{}", suffix);
731 let use_ident = quote::format_ident!("_MINIEXTENDR_IMPL_METHOD_TAG_USE_{}", suffix);
732 warning_id += 1;
733 warnings.extend(quote_spanned! { span =>
734 #[deprecated(note = #msg)]
735 #[doc(hidden)]
736 #[allow(dead_code, non_upper_case_globals)]
737 const #ident: () = ();
738 #[doc(hidden)]
739 #[allow(dead_code, non_upper_case_globals)]
740 const #use_ident: () = #ident;
741 });
742 }
743
744 (filtered, warnings)
745}
746
747/// Extract the set of parameter names declared via `@param` in a list of roxygen tags.
748///
749/// For each `@param <names> <desc>` tag in `tags`, extracts `<names>` and inserts
750/// each comma-separated name into the returned `HashSet`. roxygen2 supports
751/// documenting several params in one tag (`@param a,b,c desc` documents `a`,
752/// `b`, and `c`) — the name token is split on `,` (and each piece trimmed
753/// defensively, though roxygen2's own syntax has no spaces around the commas)
754/// so multi-name tags don't collapse to a single name. Used by R6 class
755/// generators to build the set of class-level params so method param loops
756/// can suppress `(no documentation available)` for names already covered at
757/// class level (roxygen2 8.0.0 inherits class-level `@param` tags into all
758/// methods automatically).
759pub(crate) fn extract_param_names(tags: &[String]) -> HashSet<String> {
760 let mut names = HashSet::new();
761 for tag in tags {
762 let Some(names_token) = param_names_token(tag) else {
763 continue;
764 };
765 for name in names_token.split(',') {
766 let name = name.trim();
767 if !name.is_empty() {
768 names.insert(name.to_string());
769 }
770 }
771 }
772 names
773}
774
775/// Returns `true` if `name` is documented by any `@param` tag in `tags`.
776///
777/// A tag documents `name` if `name` is exactly one of the comma-separated
778/// names in its `@param <names> <desc>` name token (see
779/// [`extract_param_names`]). This is an **exact-membership** check, not a
780/// prefix match — `starts_with(&format!("@param {name}"))` was the previous
781/// (buggy) approach: it misses every name after the first in a comma-list
782/// tag (`@param a,b,c desc` "documents" only `a`, so `b`/`c` get a spurious
783/// `(no documentation available)` filler that roxygen2 then merges into a
784/// duplicate `\item{b}` and `\item{c}` in the rendered Rd — an
785/// `R CMD check` `checking Rd \usage sections` WARNING), and it also
786/// false-positives on names that are prefixes of other names (`@param x2
787/// desc` looks like it documents `x` too).
788pub(crate) fn param_documented(tags: &[String], name: &str) -> bool {
789 tags.iter().any(|tag| tag_documents_param(tag, name))
790}
791
792/// Like [`param_documented`] but returns the matching tag itself rather than
793/// a bool, for callers that reuse the tag's rendered text verbatim (S7
794/// constructor param doc forwarding pushes the found tag as-is).
795pub(crate) fn find_param_tag<'a>(tags: &'a [String], name: &str) -> Option<&'a String> {
796 tags.iter().find(|tag| tag_documents_param(tag, name))
797}
798
799/// Shared predicate: does this single `@param` tag document `name`?
800fn tag_documents_param(tag: &str, name: &str) -> bool {
801 let Some(names_token) = param_names_token(tag) else {
802 return false;
803 };
804 names_token.split(',').any(|n| n.trim() == name)
805}
806
807/// Returns the `@param` name token (e.g. `a,b,c` in `@param a,b,c desc`) of a
808/// single tag, or `None` if `tag` isn't an `@param` tag.
809fn param_names_token(tag: &str) -> Option<&str> {
810 let trimmed = tag.trim_start();
811 let rest = trimmed.strip_prefix("@param ")?;
812 rest.split_whitespace().next()
813}
814
815/// Split an R formals/argument string on **top-level** commas only.
816///
817/// Commas nested inside parentheses, brackets, or braces — or inside a single-
818/// or double-quoted string literal — are ignored. So `x, mode = c("a", "b"), ...`
819/// yields `["x", "mode = c(\"a\", \"b\")", "..."]`, whereas a naive
820/// `split(", ")` wrongly breaks the `c("a", "b")` default into two bogus
821/// formals (`mode = c("a"` and `"b")`) — the source of spurious `@param "b")`
822/// roxygen entries on match_arg'd trait-method shortcuts (ScalerS7 / ScalerR6).
823pub(crate) fn split_r_formals(formals: &str) -> Vec<&str> {
824 let bytes = formals.as_bytes();
825 let mut out = Vec::new();
826 let mut depth: i32 = 0;
827 let mut quote: Option<u8> = None;
828 let mut start = 0usize;
829 let mut i = 0usize;
830 while i < bytes.len() {
831 let b = bytes[i];
832 match quote {
833 Some(q) => {
834 if b == b'\\' {
835 i += 1; // skip the escaped char
836 } else if b == q {
837 quote = None;
838 }
839 }
840 None => match b {
841 b'"' | b'\'' => quote = Some(b),
842 b'(' | b'[' | b'{' => depth += 1,
843 b')' | b']' | b'}' => depth -= 1,
844 b',' if depth == 0 => {
845 out.push(formals[start..i].trim());
846 start = i + 1;
847 }
848 _ => {}
849 },
850 }
851 i += 1;
852 }
853 out.push(formals[start..].trim());
854 out.into_iter().filter(|s| !s.is_empty()).collect()
855}
856
857/// Extract the R parameter name from a single formal (e.g. `mode = c("a","b")`
858/// → `mode`). Pair with [`split_r_formals`], never a raw `split(',')`.
859pub(crate) fn formal_name(formal: &str) -> &str {
860 formal.split('=').next().unwrap_or(formal).trim()
861}
862
863/// Like [`strip_method_tags`] but for R6 impl blocks.
864///
865/// R6 class-level `@param` tags are **kept** (roxygen2 8.0.0 inherits them into
866/// all methods automatically — rd-R6.Rmd §"Class-level docs"). All other
867/// method-only tags (`@return`, `@returns`, `@examples`, `@export`) are still
868/// stripped with a compile-time warning. No warning is generated for `@param`.
869///
870/// Returns `(filtered_tags, warnings)` — same shape as [`strip_method_tags`].
871/// `block_id` is the per-impl-block disambiguator (see
872/// [`next_impl_tag_block_id`]) that keeps warning-const names unique (#1118).
873pub(crate) fn strip_method_tags_r6(
874 tags: &[String],
875 type_name: &str,
876 block_id: usize,
877 span: proc_macro2::Span,
878) -> (Vec<String>, proc_macro2::TokenStream) {
879 use quote::quote_spanned;
880
881 let mut filtered = Vec::new();
882 let mut warnings = proc_macro2::TokenStream::new();
883 let mut warning_id: usize = 0;
884
885 for tag in tags {
886 let Some(name) = roxygen_tag_name(tag) else {
887 filtered.push(tag.clone());
888 continue;
889 };
890 if !METHOD_ONLY_TAGS_R6.contains(&name) {
891 // Keeps @param (and any unrecognised tags) without warning.
892 filtered.push(tag.clone());
893 continue;
894 }
895 let msg = format!(
896 "miniextendr: @{} on impl block `{}` has no effect — move it to the method. Tag: {}",
897 name,
898 type_name,
899 tag.trim()
900 );
901 let suffix = format!(
902 "{}_{}_{}",
903 type_name.replace(|c: char| !c.is_alphanumeric(), "_"),
904 block_id,
905 warning_id
906 );
907 let ident = quote::format_ident!("_MINIEXTENDR_IMPL_METHOD_TAG_WARN_{}", suffix);
908 let use_ident = quote::format_ident!("_MINIEXTENDR_IMPL_METHOD_TAG_USE_{}", suffix);
909 warning_id += 1;
910 warnings.extend(quote_spanned! { span =>
911 #[deprecated(note = #msg)]
912 #[doc(hidden)]
913 #[allow(dead_code, non_upper_case_globals)]
914 const #ident: () = ();
915 #[doc(hidden)]
916 #[allow(dead_code, non_upper_case_globals)]
917 const #use_ident: () = #ident;
918 });
919 }
920
921 (filtered, warnings)
922}
923
924/// Strip roxygen tag lines from doc attributes, keeping only regular documentation.
925///
926/// Returns a new vector of attributes with roxygen lines removed from doc comments.
927/// Non-doc attributes are passed through unchanged.
928///
929/// # Algorithm
930///
931/// Roxygen tags typically appear at the end of documentation blocks. We use a simple
932/// but effective approach:
933/// 1. Keep all content before the first `@tag` line
934/// 2. Strip everything from the first `@tag` to the end of the roxygen region
935///
936/// A roxygen region ends when we see a non-empty line that doesn't start with `@`
937/// and follows an empty line (paragraph break). This handles multi-paragraph tags.
938pub(crate) fn strip_roxygen_from_attrs(attrs: &[syn::Attribute]) -> Vec<syn::Attribute> {
939 // Collect doc attribute indices and their trimmed content
940 let mut doc_info: Vec<(usize, String)> = Vec::new();
941 for (i, attr) in attrs.iter().enumerate() {
942 if !attr.path().is_ident("doc") {
943 continue;
944 }
945 let syn::Meta::NameValue(nv) = &attr.meta else {
946 continue;
947 };
948 let syn::Expr::Lit(expr_lit) = &nv.value else {
949 continue;
950 };
951 let syn::Lit::Str(lit) = &expr_lit.lit else {
952 continue;
953 };
954 // Trim the leading space that comes from `/// `
955 doc_info.push((i, lit.value().trim_start().to_string()));
956 }
957
958 // Find roxygen line indices
959 let mut roxygen_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
960 let mut in_roxygen = false;
961 let mut prev_was_empty = false;
962
963 for (i, trimmed) in &doc_info {
964 if trimmed.starts_with('@') {
965 // Start or continue roxygen region
966 in_roxygen = true;
967 roxygen_indices.insert(*i);
968 prev_was_empty = false;
969 } else if in_roxygen {
970 if trimmed.is_empty() {
971 // Empty line in roxygen - might end the block or be part of multi-paragraph tag
972 roxygen_indices.insert(*i);
973 prev_was_empty = true;
974 } else if prev_was_empty {
975 // Non-empty line after empty line - end roxygen region
976 // This is likely regular documentation
977 in_roxygen = false;
978 prev_was_empty = false;
979 } else {
980 // Continuation line (no paragraph break)
981 roxygen_indices.insert(*i);
982 }
983 }
984 }
985
986 // Build result excluding roxygen lines
987 attrs
988 .iter()
989 .enumerate()
990 .filter(|(i, _)| !roxygen_indices.contains(i))
991 .map(|(_, attr)| attr.clone())
992 .collect()
993}
994
995#[cfg(test)]
996mod tests;