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/// Filter out method-specific roxygen tags from impl-block-level docs and emit
665/// compile warnings for each stripped tag.
666///
667/// Method-specific tags (`@param`, `@return`, `@returns`, `@examples`,
668/// `@export`) on an impl block are meaningless — they belong on individual
669/// methods, or (for `@export`) are emitted by `ClassDocBuilder`. When users
670/// put them on impl blocks, the tags leak into the class-level Rd file where
671/// R CMD check warns about "documented arguments not in \\usage" and similar.
672///
673/// Returns the filtered tags and a TokenStream of deprecation warnings for
674/// each stripped tag. The caller should append the warnings to its output so
675/// the user sees them at compile time.
676pub(crate) fn strip_method_tags(
677 tags: &[String],
678 type_name: &str,
679 span: proc_macro2::Span,
680) -> (Vec<String>, proc_macro2::TokenStream) {
681 use quote::quote_spanned;
682
683 let mut filtered = Vec::new();
684 let mut warnings = proc_macro2::TokenStream::new();
685 let mut warning_id: usize = 0;
686
687 for tag in tags {
688 let Some(name) = roxygen_tag_name(tag) else {
689 filtered.push(tag.clone());
690 continue;
691 };
692 if !METHOD_ONLY_TAGS.contains(&name) {
693 filtered.push(tag.clone());
694 continue;
695 }
696 let msg = format!(
697 "miniextendr: @{} on impl block `{}` has no effect — move it to the method. Tag: {}",
698 name,
699 type_name,
700 tag.trim()
701 );
702 let ident = quote::format_ident!(
703 "_MINIEXTENDR_IMPL_METHOD_TAG_WARN_{}_{}",
704 type_name.replace(|c: char| !c.is_alphanumeric(), "_"),
705 warning_id
706 );
707 warning_id += 1;
708 warnings.extend(quote_spanned! { span =>
709 #[deprecated(note = #msg)]
710 #[doc(hidden)]
711 #[allow(dead_code)]
712 const #ident: () = ();
713 });
714 }
715
716 (filtered, warnings)
717}
718
719/// Extract the set of parameter names declared via `@param` in a list of roxygen tags.
720///
721/// For each `@param <name> <desc>` tag in `tags`, extracts `<name>` and inserts it
722/// into the returned `HashSet`. Used by R6 class generators to build the set of
723/// class-level params so method param loops can suppress `(no documentation available)`
724/// for names already covered at class level (roxygen2 8.0.0 inherits class-level
725/// `@param` tags into all methods automatically).
726pub(crate) fn extract_param_names(tags: &[String]) -> HashSet<String> {
727 let mut names = HashSet::new();
728 for tag in tags {
729 let trimmed = tag.trim_start();
730 if let Some(rest) = trimmed.strip_prefix("@param ") {
731 let name = rest.split_whitespace().next().unwrap_or("").to_string();
732 if !name.is_empty() {
733 names.insert(name);
734 }
735 }
736 }
737 names
738}
739
740/// Split an R formals/argument string on **top-level** commas only.
741///
742/// Commas nested inside parentheses, brackets, or braces — or inside a single-
743/// or double-quoted string literal — are ignored. So `x, mode = c("a", "b"), ...`
744/// yields `["x", "mode = c(\"a\", \"b\")", "..."]`, whereas a naive
745/// `split(", ")` wrongly breaks the `c("a", "b")` default into two bogus
746/// formals (`mode = c("a"` and `"b")`) — the source of spurious `@param "b")`
747/// roxygen entries on match_arg'd trait-method shortcuts (ScalerS7 / ScalerR6).
748pub(crate) fn split_r_formals(formals: &str) -> Vec<&str> {
749 let bytes = formals.as_bytes();
750 let mut out = Vec::new();
751 let mut depth: i32 = 0;
752 let mut quote: Option<u8> = None;
753 let mut start = 0usize;
754 let mut i = 0usize;
755 while i < bytes.len() {
756 let b = bytes[i];
757 match quote {
758 Some(q) => {
759 if b == b'\\' {
760 i += 1; // skip the escaped char
761 } else if b == q {
762 quote = None;
763 }
764 }
765 None => match b {
766 b'"' | b'\'' => quote = Some(b),
767 b'(' | b'[' | b'{' => depth += 1,
768 b')' | b']' | b'}' => depth -= 1,
769 b',' if depth == 0 => {
770 out.push(formals[start..i].trim());
771 start = i + 1;
772 }
773 _ => {}
774 },
775 }
776 i += 1;
777 }
778 out.push(formals[start..].trim());
779 out.into_iter().filter(|s| !s.is_empty()).collect()
780}
781
782/// Extract the R parameter name from a single formal (e.g. `mode = c("a","b")`
783/// → `mode`). Pair with [`split_r_formals`], never a raw `split(',')`.
784pub(crate) fn formal_name(formal: &str) -> &str {
785 formal.split('=').next().unwrap_or(formal).trim()
786}
787
788/// Like [`strip_method_tags`] but for R6 impl blocks.
789///
790/// R6 class-level `@param` tags are **kept** (roxygen2 8.0.0 inherits them into
791/// all methods automatically — rd-R6.Rmd §"Class-level docs"). All other
792/// method-only tags (`@return`, `@returns`, `@examples`, `@export`) are still
793/// stripped with a compile-time warning. No warning is generated for `@param`.
794///
795/// Returns `(filtered_tags, warnings)` — same shape as [`strip_method_tags`].
796pub(crate) fn strip_method_tags_r6(
797 tags: &[String],
798 type_name: &str,
799 span: proc_macro2::Span,
800) -> (Vec<String>, proc_macro2::TokenStream) {
801 use quote::quote_spanned;
802
803 let mut filtered = Vec::new();
804 let mut warnings = proc_macro2::TokenStream::new();
805 let mut warning_id: usize = 0;
806
807 for tag in tags {
808 let Some(name) = roxygen_tag_name(tag) else {
809 filtered.push(tag.clone());
810 continue;
811 };
812 if !METHOD_ONLY_TAGS_R6.contains(&name) {
813 // Keeps @param (and any unrecognised tags) without warning.
814 filtered.push(tag.clone());
815 continue;
816 }
817 let msg = format!(
818 "miniextendr: @{} on impl block `{}` has no effect — move it to the method. Tag: {}",
819 name,
820 type_name,
821 tag.trim()
822 );
823 let ident = quote::format_ident!(
824 "_MINIEXTENDR_IMPL_METHOD_TAG_WARN_{}_{}",
825 type_name.replace(|c: char| !c.is_alphanumeric(), "_"),
826 warning_id
827 );
828 warning_id += 1;
829 warnings.extend(quote_spanned! { span =>
830 #[deprecated(note = #msg)]
831 #[doc(hidden)]
832 #[allow(dead_code)]
833 const #ident: () = ();
834 });
835 }
836
837 (filtered, warnings)
838}
839
840/// Strip roxygen tag lines from doc attributes, keeping only regular documentation.
841///
842/// Returns a new vector of attributes with roxygen lines removed from doc comments.
843/// Non-doc attributes are passed through unchanged.
844///
845/// # Algorithm
846///
847/// Roxygen tags typically appear at the end of documentation blocks. We use a simple
848/// but effective approach:
849/// 1. Keep all content before the first `@tag` line
850/// 2. Strip everything from the first `@tag` to the end of the roxygen region
851///
852/// A roxygen region ends when we see a non-empty line that doesn't start with `@`
853/// and follows an empty line (paragraph break). This handles multi-paragraph tags.
854pub(crate) fn strip_roxygen_from_attrs(attrs: &[syn::Attribute]) -> Vec<syn::Attribute> {
855 // Collect doc attribute indices and their trimmed content
856 let mut doc_info: Vec<(usize, String)> = Vec::new();
857 for (i, attr) in attrs.iter().enumerate() {
858 if !attr.path().is_ident("doc") {
859 continue;
860 }
861 let syn::Meta::NameValue(nv) = &attr.meta else {
862 continue;
863 };
864 let syn::Expr::Lit(expr_lit) = &nv.value else {
865 continue;
866 };
867 let syn::Lit::Str(lit) = &expr_lit.lit else {
868 continue;
869 };
870 // Trim the leading space that comes from `/// `
871 doc_info.push((i, lit.value().trim_start().to_string()));
872 }
873
874 // Find roxygen line indices
875 let mut roxygen_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
876 let mut in_roxygen = false;
877 let mut prev_was_empty = false;
878
879 for (i, trimmed) in &doc_info {
880 if trimmed.starts_with('@') {
881 // Start or continue roxygen region
882 in_roxygen = true;
883 roxygen_indices.insert(*i);
884 prev_was_empty = false;
885 } else if in_roxygen {
886 if trimmed.is_empty() {
887 // Empty line in roxygen - might end the block or be part of multi-paragraph tag
888 roxygen_indices.insert(*i);
889 prev_was_empty = true;
890 } else if prev_was_empty {
891 // Non-empty line after empty line - end roxygen region
892 // This is likely regular documentation
893 in_roxygen = false;
894 prev_was_empty = false;
895 } else {
896 // Continuation line (no paragraph break)
897 roxygen_indices.insert(*i);
898 }
899 }
900 }
901
902 // Build result excluding roxygen lines
903 attrs
904 .iter()
905 .enumerate()
906 .filter(|(i, _)| !roxygen_indices.contains(i))
907 .map(|(_, attr)| attr.clone())
908 .collect()
909}
910
911#[cfg(test)]
912mod tests;