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, false)
68}
69
70/// Extract roxygen tags with optional auto-description for impl methods.
71///
72/// If `auto_description = true` and no explicit `@tag` is found, the first
73/// paragraph of regular doc comments is converted to `@description`.
74///
75/// Used for all class systems (R6, S3, S4, S7, Env) to automatically
76/// convert Rust doc comments into roxygen `@description` tags.
77pub(crate) fn roxygen_tags_from_attrs_for_r6_method(attrs: &[syn::Attribute]) -> Vec<String> {
78    roxygen_tags_from_attrs_impl(attrs, true)
79}
80
81/// Core implementation of roxygen tag extraction from `#[doc = "..."]` attributes.
82///
83/// Walks through doc attributes line by line. Lines starting with `@` begin a new tag.
84/// Continuation lines are appended only if the current tag is multiline-capable.
85///
86/// When `auto_description` is true and no `@tag` lines are found, the first paragraph
87/// of regular doc comments is auto-converted to `@description`.
88///
89/// Also auto-generates `@title` from the implicit title (first sentence) when tags
90/// are present but `@title` is missing, and `@description` when `@name` is present
91/// but `@description` is missing.
92fn roxygen_tags_from_attrs_impl(attrs: &[syn::Attribute], auto_description: bool) -> Vec<String> {
93    let mut tags = Vec::new();
94    let mut regular_docs = Vec::new();
95    // Track whether we have seen doc content followed by a non-doc attribute.
96    // When that happens, subsequent bare prose must NOT continue into any open
97    // multiline tag (it would corrupt @examples / @details / @return blocks).
98    // Instead we reset the multiline-continuation context so the trailing prose
99    // is treated as new regular_docs material (if tags is still empty) or is
100    // simply ignored as a continuation.
101    let mut interrupted_by_non_doc = false;
102
103    for attr in attrs {
104        if !attr.path().is_ident("doc") {
105            // Non-doc attribute between doc runs — set interruption flag if we
106            // have already collected some doc content.
107            if !tags.is_empty() || !regular_docs.is_empty() {
108                interrupted_by_non_doc = true;
109            }
110            continue;
111        }
112        let syn::Meta::NameValue(nv) = &attr.meta else {
113            continue;
114        };
115        let syn::Expr::Lit(expr_lit) = &nv.value else {
116            continue;
117        };
118        let syn::Lit::Str(lit) = &expr_lit.lit else {
119            continue;
120        };
121        for line in lit.value().lines() {
122            let trimmed = line.trim_start();
123            if trimmed.starts_with('@') {
124                // New tag starts — interruption no longer relevant for tags
125                interrupted_by_non_doc = false;
126                tags.push(trimmed.to_string());
127            } else if !trimmed.is_empty() {
128                if tags.is_empty() {
129                    // Before any @tags - collect as regular docs
130                    // (allowed even after an interruption — user prose, not tag continuation)
131                    regular_docs.push(trimmed.to_string());
132                } else if interrupted_by_non_doc {
133                    // Bare prose after a non-doc attribute interruption:
134                    // do NOT append to any open multiline tag — that would
135                    // corrupt @examples / @details / @return content.
136                    // The prose is dropped for tag-continuation purposes.
137                } else if let Some(last) = tags.last_mut()
138                    && is_multiline_tag(last)
139                {
140                    // Continuation line for multi-line tags only
141                    last.push('\n');
142                    last.push_str(trimmed);
143                }
144                // Single-line tags: ignore continuation lines
145            }
146        }
147    }
148
149    // Check which tags are present
150    let tag_names_set = tag_names(&tags);
151    let has_name = tag_names_set.contains("name") || tag_names_set.contains("rdname");
152    let has_title = tag_names_set.contains("title");
153    let has_description = tag_names_set.contains("description");
154    let has_any_tags = !tags.is_empty();
155
156    // Auto-generate @title from implicit title if:
157    // - We have @name but no @title, OR
158    // - We have any tags (like @param/@return) but no @title (user is writing roxygen docs)
159    // Use implicit_title_from_attrs which respects paragraph breaks
160    if (has_name || has_any_tags)
161        && !has_title
162        && let Some(title) = implicit_title_from_attrs(attrs)
163    {
164        tags.insert(0, format!("@title {}", title));
165    }
166
167    // Auto-generate @description from implicit description (second paragraph) if:
168    // - We have @name or any tags (like @param/@return) but no @description
169    // Mirrors the @title auto-generation condition above
170    if (has_name || has_any_tags)
171        && !has_description
172        && let Some(desc) = implicit_description_from_attrs(attrs)
173    {
174        // Insert after @title if present, otherwise at start
175        let insert_pos = if tags.first().is_some_and(|t| t.starts_with("@title")) {
176            1
177        } else {
178            0
179        };
180        tags.insert(insert_pos, format!("@description {}", desc));
181    }
182
183    // Auto-generate @details from implicit details (paragraphs 3+) if:
184    // - We have @name or any tags but no explicit @details
185    // - There are 3+ free-form paragraphs before the first @tag
186    let has_details = tag_names(&tags).contains("details");
187    if (has_name || has_any_tags)
188        && !has_details
189        && let Some(details) = implicit_details_from_attrs(attrs)
190    {
191        // Insert after @title + @description if present
192        let insert_pos = tags
193            .iter()
194            .take_while(|t| t.starts_with("@title") || t.starts_with("@description"))
195            .count();
196        tags.insert(insert_pos, format!("@details {}", details));
197    }
198
199    // Auto-description for impl methods that have no explicit @tags.
200    // Follows roxygen2 convention: paragraph 1 → @title, paragraph 2 → @description,
201    // paragraphs 3+ → @details.  The three implicit_*_from_attrs helpers parse the
202    // raw attrs so they correctly handle paragraph boundaries even when there is no
203    // blank-line separator encoded in the pre-collected `regular_docs`.
204    if auto_description && tags.is_empty() && !regular_docs.is_empty() {
205        if let Some(title) = implicit_title_from_attrs(attrs) {
206            tags.push(format!("@title {}", title));
207        }
208        if let Some(desc) = implicit_description_from_attrs(attrs) {
209            tags.push(format!("@description {}", desc));
210        }
211        if let Some(details) = implicit_details_from_attrs(attrs) {
212            tags.push(format!("@details {}", details));
213        }
214    }
215
216    tags
217}
218
219/// Render roxygen tag lines as "#' ..." comment lines.
220///
221/// Multiline tags (containing '\n') are split into separate `#'` lines.
222pub(crate) fn format_roxygen_tags(tags: &[String]) -> String {
223    if tags.is_empty() {
224        return String::new();
225    }
226    let mut out = String::new();
227    for tag in tags {
228        for line in tag.lines() {
229            out.push_str("#' ");
230            out.push_str(line);
231            out.push('\n');
232        }
233    }
234    out
235}
236
237/// Push roxygen tag lines into a vector of R wrapper lines.
238///
239/// Multiline tags (containing '\n') are split into separate `#'` lines.
240pub(crate) fn push_roxygen_tags(lines: &mut Vec<String>, tags: &[String]) {
241    for tag in tags {
242        for line in tag.lines() {
243            lines.push(format!("#' {}", line));
244        }
245    }
246}
247
248/// Like [`push_roxygen_tags`] but takes `&[&str]` for filtered tag slices.
249pub(crate) fn push_roxygen_tags_str(lines: &mut Vec<String>, tags: &[&str]) {
250    for tag in tags {
251        for line in tag.lines() {
252            lines.push(format!("#' {}", line));
253        }
254    }
255}
256
257/// Return true if the tag list contains a specific roxygen tag.
258///
259/// Supports both single-word tags (e.g., `"export"`, `"noRd"`) and
260/// multi-word tags (e.g., `"keywords internal"`). For single-word tags,
261/// matches the first word after `@`. For multi-word tags, matches the
262/// full content after `@` (trimmed).
263pub(crate) fn has_roxygen_tag(tags: &[String], tag: &str) -> bool {
264    if tag.contains(' ') {
265        // Multi-word tag: match the full content after @
266        tags.iter().any(|t| {
267            t.trim_start()
268                .strip_prefix('@')
269                .is_some_and(|rest| rest.trim() == tag)
270        })
271    } else {
272        tag_names(tags).contains(tag)
273    }
274}
275
276/// Build a roxygen `@source` traceability line for a class method.
277///
278/// Returns `"#' @source Generated by miniextendr from \`Type::method\`"`.
279/// Use this wherever a class-generator needs to emit a source-provenance
280/// comment linking the generated R wrapper back to the originating Rust
281/// `impl` block method.
282pub(crate) fn method_source_tag(type_ident: &syn::Ident, method_ident: &syn::Ident) -> String {
283    format!(
284        "#' @source Generated by miniextendr from `{}::{}`",
285        type_ident, method_ident
286    )
287}
288
289/// Build a roxygen `@source` traceability line for a class definition.
290///
291/// Returns ``"#' @source Generated by miniextendr from Rust type `Type`"``.
292/// Companion to [`method_source_tag`] for class-level documentation blocks.
293pub(crate) fn class_source_tag(type_ident: &syn::Ident) -> String {
294    format!(
295        "#' @source Generated by miniextendr from Rust type `{}`",
296        type_ident
297    )
298}
299
300/// Extract the set of tag names from a list of roxygen tag strings.
301///
302/// Each tag string is expected to start with `@tagname`. Returns a set of
303/// the tag names (without the `@` prefix).
304fn tag_names(tags: &[String]) -> HashSet<&str> {
305    let mut names = HashSet::new();
306    for tag in tags {
307        let trimmed = tag.trim_start();
308        let name = trimmed
309            .strip_prefix('@')
310            .and_then(|rest| rest.split_whitespace().next());
311        if let Some(name) = name {
312            names.insert(name);
313        }
314    }
315    names
316}
317
318/// Find the value of a specific roxygen tag (e.g., "title" for `@title ...`).
319///
320/// Returns `None` if the tag is not present or has no value.
321#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
322pub(crate) fn find_tag_value<'a>(tags: &'a [String], tag_name: &str) -> Option<&'a str> {
323    for tag in tags {
324        let trimmed = tag.trim_start();
325        if let Some(rest) = trimmed.strip_prefix('@') {
326            let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
327            if let Some(name) = parts.next()
328                && name == tag_name
329            {
330                // Get the value (everything after the tag name)
331                return parts.next().map(|s| s.trim());
332            }
333        }
334    }
335    None
336}
337
338/// Normalize text for comparison: lowercase, collapse whitespace, strip trailing punctuation.
339///
340/// Used by `doc_conflict_warnings` to compare explicit `@title`/`@description` values
341/// with implicit values derived from the doc comment structure. Normalization ensures
342/// minor formatting differences (extra spaces, trailing periods) don't trigger false warnings.
343#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
344fn normalize_for_comparison(s: &str) -> String {
345    let lower = s.to_lowercase();
346    let mut result = String::new();
347    for word in lower.split_whitespace() {
348        if !result.is_empty() {
349            result.push(' ');
350        }
351        result.push_str(word);
352    }
353    result.truncate(
354        result
355            .trim_end_matches(|c: char| c.is_ascii_punctuation())
356            .len(),
357    );
358    result
359}
360
361/// Extract the implicit title from doc attributes (first sentence, up to first `.` or newline).
362///
363/// Returns `None` if there are no doc comments or if docs start with a `@tag`.
364#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
365pub(crate) fn implicit_title_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
366    let mut lines = Vec::new();
367
368    for attr in attrs {
369        if !attr.path().is_ident("doc") {
370            continue;
371        }
372        let syn::Meta::NameValue(nv) = &attr.meta else {
373            continue;
374        };
375        let syn::Expr::Lit(expr_lit) = &nv.value else {
376            continue;
377        };
378        let syn::Lit::Str(lit) = &expr_lit.lit else {
379            continue;
380        };
381
382        let content = lit.value();
383        let trimmed = content.trim();
384
385        // If we hit a @tag before any content, there's no implicit title
386        if trimmed.starts_with('@') {
387            if lines.is_empty() {
388                return None;
389            }
390            break;
391        }
392
393        // Empty line ends first sentence for title extraction
394        if trimmed.is_empty() {
395            break;
396        }
397
398        // Check if this line contains a sentence-ending period
399        if let Some(pos) = trimmed.find(". ") {
400            lines.push(trimmed[..pos].to_string());
401            break;
402        } else if trimmed.ends_with('.') {
403            lines.push(trimmed.trim_end_matches('.').to_string());
404            break;
405        } else {
406            lines.push(trimmed.to_string());
407        }
408    }
409
410    if lines.is_empty() {
411        None
412    } else {
413        Some(lines.join(" "))
414    }
415}
416
417/// Extract the implicit description from doc attributes (second paragraph).
418///
419/// In roxygen2, the first paragraph is the title and the second paragraph is the
420/// description. This function skips the first paragraph (up to the first blank line)
421/// and returns the second paragraph.
422///
423/// Returns `None` if there is no second paragraph, no doc comments, or if docs
424/// start with a `@tag`.
425#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
426pub(crate) fn implicit_description_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
427    let mut lines = Vec::new();
428    let mut found_first_paragraph = false;
429    let mut in_gap = false;
430
431    for attr in attrs {
432        if !attr.path().is_ident("doc") {
433            continue;
434        }
435        let syn::Meta::NameValue(nv) = &attr.meta else {
436            continue;
437        };
438        let syn::Expr::Lit(expr_lit) = &nv.value else {
439            continue;
440        };
441        let syn::Lit::Str(lit) = &expr_lit.lit else {
442            continue;
443        };
444
445        let content = lit.value();
446        let trimmed = content.trim();
447
448        // If we hit a @tag, stop
449        if trimmed.starts_with('@') {
450            break;
451        }
452
453        if !found_first_paragraph {
454            // Still in the first paragraph (title)
455            if trimmed.is_empty() {
456                // Blank line — first paragraph ended, now in the gap
457                found_first_paragraph = true;
458                in_gap = true;
459            }
460            // Non-empty lines before first blank are title — skip
461        } else if in_gap {
462            // Between paragraphs — skip blank lines
463            if !trimmed.is_empty() {
464                // Start of second paragraph
465                in_gap = false;
466                lines.push(trimmed.to_string());
467            }
468        } else {
469            // In second paragraph
470            if trimmed.is_empty() {
471                // End of second paragraph
472                break;
473            }
474            lines.push(trimmed.to_string());
475        }
476    }
477
478    if lines.is_empty() {
479        None
480    } else {
481        Some(lines.join(" "))
482    }
483}
484
485/// Extract the implicit details from doc attributes (paragraphs 3+).
486///
487/// In roxygen2, paragraph 1 is the title, paragraph 2 is the description,
488/// and paragraphs 3+ become `\details{}`. This function skips paragraphs 1 and 2
489/// and returns the remaining free-form paragraphs (before the first `@tag`)
490/// joined with `\n\n`.
491///
492/// Returns `None` if there are fewer than 3 paragraphs, no doc comments, or if
493/// docs start with a `@tag`.
494#[cfg_attr(not(feature = "doc-lint"), allow(dead_code))]
495pub(crate) fn implicit_details_from_attrs(attrs: &[syn::Attribute]) -> Option<String> {
496    // paragraph_index: 0=title, 1=description, 2+=details
497    let mut paragraph_index: usize = 0;
498    let mut in_gap = false;
499    let mut detail_paragraphs: Vec<Vec<String>> = Vec::new();
500    let mut current_paragraph: Vec<String> = Vec::new();
501
502    for attr in attrs {
503        if !attr.path().is_ident("doc") {
504            continue;
505        }
506        let syn::Meta::NameValue(nv) = &attr.meta else {
507            continue;
508        };
509        let syn::Expr::Lit(expr_lit) = &nv.value else {
510            continue;
511        };
512        let syn::Lit::Str(lit) = &expr_lit.lit else {
513            continue;
514        };
515
516        let content = lit.value();
517        let trimmed = content.trim();
518
519        // Stop at the first @tag
520        if trimmed.starts_with('@') {
521            break;
522        }
523
524        if trimmed.is_empty() {
525            // Blank line — paragraph boundary
526            if !in_gap {
527                // End of the current paragraph
528                if paragraph_index >= 2 && !current_paragraph.is_empty() {
529                    detail_paragraphs.push(std::mem::take(&mut current_paragraph));
530                } else {
531                    current_paragraph.clear();
532                }
533                paragraph_index += 1;
534                in_gap = true;
535            }
536            // Multiple blank lines in a row: stay in gap
537        } else {
538            // Non-empty line
539            in_gap = false;
540            if paragraph_index >= 2 {
541                current_paragraph.push(trimmed.to_string());
542            }
543            // paragraphs 0 (title) and 1 (description) are skipped
544        }
545    }
546
547    // Handle a details paragraph that isn't terminated by a blank line / @tag
548    if paragraph_index >= 2 && !current_paragraph.is_empty() {
549        detail_paragraphs.push(current_paragraph);
550    }
551
552    if detail_paragraphs.is_empty() {
553        None
554    } else {
555        let joined: Vec<String> = detail_paragraphs.into_iter().map(|p| p.join(" ")).collect();
556        Some(joined.join("\n\n"))
557    }
558}
559
560/// Check for conflicts between explicit `@title`/`@description` tags and implicit values.
561///
562/// When the `doc-lint` feature is enabled, returns tokens that generate compile-time
563/// deprecation warnings if explicit roxygen tags differ from the implicit values
564/// derived from the doc comment structure.
565///
566/// The returned tokens should be appended to the macro expansion output.
567#[cfg(feature = "doc-lint")]
568pub(crate) fn doc_conflict_warnings(
569    attrs: &[syn::Attribute],
570    _span: proc_macro2::Span,
571) -> proc_macro2::TokenStream {
572    use quote::quote;
573
574    let tags = roxygen_tags_from_attrs(attrs);
575    let mut warnings = proc_macro2::TokenStream::new();
576
577    // Check @title conflict
578    if let Some(explicit) = find_tag_value(&tags, "title")
579        && let Some(implicit) = implicit_title_from_attrs(attrs)
580        && normalize_for_comparison(explicit) != normalize_for_comparison(&implicit)
581    {
582        let msg = format!(
583            "miniextendr doc-lint: explicit @title differs from first doc line. \
584             R's roxygen2 uses the first line as the title. \
585             implicit: \"{}\", explicit @title: \"{}\"",
586            implicit, explicit
587        );
588        warnings.extend(quote! {
589            const _: () = {
590                #[deprecated(note = #msg)]
591                #[doc(hidden)]
592                #[allow(dead_code)]
593                const MINIEXTENDR_DOC_LINT_TITLE: () = ();
594                let _ = MINIEXTENDR_DOC_LINT_TITLE;
595            };
596        });
597    }
598
599    // Check @description conflict
600    if let Some(explicit) = find_tag_value(&tags, "description")
601        && let Some(implicit) = implicit_description_from_attrs(attrs)
602        && normalize_for_comparison(explicit) != normalize_for_comparison(&implicit)
603    {
604        let msg = format!(
605            "miniextendr doc-lint: explicit @description differs from first paragraph. \
606             R's roxygen2 uses the first paragraph as the description. \
607             implicit: \"{}\", explicit @description: \"{}\"",
608            implicit, explicit
609        );
610        warnings.extend(quote! {
611            const _: () = {
612                #[deprecated(note = #msg)]
613                #[doc(hidden)]
614                #[allow(dead_code)]
615                const MINIEXTENDR_DOC_LINT_DESC: () = ();
616                let _ = MINIEXTENDR_DOC_LINT_DESC;
617            };
618        });
619    }
620
621    warnings
622}
623
624/// No-op when doc-lint feature is disabled.
625#[cfg(not(feature = "doc-lint"))]
626pub(crate) fn doc_conflict_warnings(
627    _attrs: &[syn::Attribute],
628    _span: proc_macro2::Span,
629) -> proc_macro2::TokenStream {
630    proc_macro2::TokenStream::new()
631}
632
633/// Roxygen tags that only make sense on individual methods, not on impl blocks.
634///
635/// - `@param` — impl blocks have no parameters.
636/// - `@return` / `@returns` — impl blocks have no return value.
637/// - `@examples` — examples belong on the method that is being demonstrated.
638/// - `@export` — redundant: export for class-level docs is handled by
639///   [`ClassDocBuilder`], which emits `@export` based on the impl block's
640///   `internal` / `noexport` attrs, not on user-supplied roxygen.
641const METHOD_ONLY_TAGS: &[&str] = &["param", "return", "returns", "examples", "export"];
642
643/// Extract the tag name from a roxygen line (everything between `@` and the
644/// first whitespace character). Returns `None` for lines that don't start with
645/// a tag.
646fn roxygen_tag_name(tag: &str) -> Option<&str> {
647    let rest = tag.trim_start().strip_prefix('@')?;
648    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
649    Some(&rest[..end])
650}
651
652/// Filter out method-specific roxygen tags from impl-block-level docs and emit
653/// compile warnings for each stripped tag.
654///
655/// Method-specific tags (`@param`, `@return`, `@returns`, `@examples`,
656/// `@export`) on an impl block are meaningless — they belong on individual
657/// methods, or (for `@export`) are emitted by [`ClassDocBuilder`]. When users
658/// put them on impl blocks, the tags leak into the class-level Rd file where
659/// R CMD check warns about "documented arguments not in \\usage" and similar.
660///
661/// Returns the filtered tags and a TokenStream of deprecation warnings for
662/// each stripped tag. The caller should append the warnings to its output so
663/// the user sees them at compile time.
664pub(crate) fn strip_method_tags(
665    tags: &[String],
666    type_name: &str,
667    span: proc_macro2::Span,
668) -> (Vec<String>, proc_macro2::TokenStream) {
669    use quote::quote_spanned;
670
671    let mut filtered = Vec::new();
672    let mut warnings = proc_macro2::TokenStream::new();
673    let mut warning_id: usize = 0;
674
675    for tag in tags {
676        let Some(name) = roxygen_tag_name(tag) else {
677            filtered.push(tag.clone());
678            continue;
679        };
680        if !METHOD_ONLY_TAGS.contains(&name) {
681            filtered.push(tag.clone());
682            continue;
683        }
684        let msg = format!(
685            "miniextendr: @{} on impl block `{}` has no effect — move it to the method. Tag: {}",
686            name,
687            type_name,
688            tag.trim()
689        );
690        let ident = quote::format_ident!(
691            "_MINIEXTENDR_IMPL_METHOD_TAG_WARN_{}_{}",
692            type_name.replace(|c: char| !c.is_alphanumeric(), "_"),
693            warning_id
694        );
695        warning_id += 1;
696        warnings.extend(quote_spanned! { span =>
697            #[deprecated(note = #msg)]
698            #[doc(hidden)]
699            #[allow(dead_code)]
700            const #ident: () = ();
701        });
702    }
703
704    (filtered, warnings)
705}
706
707/// Strip roxygen tag lines from doc attributes, keeping only regular documentation.
708///
709/// Returns a new vector of attributes with roxygen lines removed from doc comments.
710/// Non-doc attributes are passed through unchanged.
711///
712/// # Algorithm
713///
714/// Roxygen tags typically appear at the end of documentation blocks. We use a simple
715/// but effective approach:
716/// 1. Keep all content before the first `@tag` line
717/// 2. Strip everything from the first `@tag` to the end of the roxygen region
718///
719/// A roxygen region ends when we see a non-empty line that doesn't start with `@`
720/// and follows an empty line (paragraph break). This handles multi-paragraph tags.
721pub(crate) fn strip_roxygen_from_attrs(attrs: &[syn::Attribute]) -> Vec<syn::Attribute> {
722    // Collect doc attribute indices and their trimmed content
723    let mut doc_info: Vec<(usize, String)> = Vec::new();
724    for (i, attr) in attrs.iter().enumerate() {
725        if !attr.path().is_ident("doc") {
726            continue;
727        }
728        let syn::Meta::NameValue(nv) = &attr.meta else {
729            continue;
730        };
731        let syn::Expr::Lit(expr_lit) = &nv.value else {
732            continue;
733        };
734        let syn::Lit::Str(lit) = &expr_lit.lit else {
735            continue;
736        };
737        // Trim the leading space that comes from `/// `
738        doc_info.push((i, lit.value().trim_start().to_string()));
739    }
740
741    // Find roxygen line indices
742    let mut roxygen_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
743    let mut in_roxygen = false;
744    let mut prev_was_empty = false;
745
746    for (i, trimmed) in &doc_info {
747        if trimmed.starts_with('@') {
748            // Start or continue roxygen region
749            in_roxygen = true;
750            roxygen_indices.insert(*i);
751            prev_was_empty = false;
752        } else if in_roxygen {
753            if trimmed.is_empty() {
754                // Empty line in roxygen - might end the block or be part of multi-paragraph tag
755                roxygen_indices.insert(*i);
756                prev_was_empty = true;
757            } else if prev_was_empty {
758                // Non-empty line after empty line - end roxygen region
759                // This is likely regular documentation
760                in_roxygen = false;
761                prev_was_empty = false;
762            } else {
763                // Continuation line (no paragraph break)
764                roxygen_indices.insert(*i);
765            }
766        }
767    }
768
769    // Build result excluding roxygen lines
770    attrs
771        .iter()
772        .enumerate()
773        .filter(|(i, _)| !roxygen_indices.contains(i))
774        .map(|(_, attr)| attr.clone())
775        .collect()
776}
777
778#[cfg(test)]
779mod tests;