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/// 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;