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;