Skip to main content

miniextendr_macros/
roxygen.rs

1//! Roxygen tag extraction and processing for R wrapper generation.
2//!
3//! This module extracts roxygen2-style tags (e.g., `@param`, `@examples`) from Rust
4//! doc comments and propagates them to generated R wrapper code.
5//!
6//! # Usage
7//!
8//! In Rust doc comments, use roxygen2 tags:
9//!
10//! ```rust,ignore
11//! /// @param x A numeric input.
12//! /// @return The squared value.
13//! /// @examples
14//! /// square(4)
15//! #[miniextendr]
16//! pub fn square(x: f64) -> f64 { x * x }
17//! ```
18//!
19//! # 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;