Skip to main content

miniextendr_macros/
r_class_formatter.rs

1//! Shared utilities for R class wrapper generation.
2//!
3//! This module provides abstractions to reduce duplication across the 5 class system
4//! generators (Env, R6, S3, S4, S7). Each class system has different R idioms but shares
5//! common patterns:
6//!
7//! - Class-level roxygen documentation
8//! - Constructor generation
9//! - Instance method iteration with `.Call()` building
10//! - Static method handling
11//! - Return strategy application
12//!
13//! ## Architecture
14//!
15//! ```text
16//! ParsedImpl
17//!     │
18//!     ├─▶ ClassDocBuilder  → roxygen header lines (#' @title, @name, etc.)
19//!     │
20//!     └─▶ MethodContext[]  → pre-computed method data for each method
21//!             │
22//!             └─▶ ClassFormatter::format_constructor()
23//!             └─▶ ClassFormatter::format_instance_method()
24//!             └─▶ ClassFormatter::format_static_method()
25//! ```
26
27use crate::miniextendr_impl::{ParsedImpl, ParsedMethod};
28
29/// Determine whether a class or method should be `@export`-ed.
30///
31/// Returns `true` unless the doc tags include `@noRd` or `@keywords internal`,
32/// or the `noexport` flag is set (which should incorporate both the `noexport`
33/// attribute and the `internal` attribute from the impl block).
34///
35/// Call sites should pass `parsed_impl.noexport || parsed_impl.internal` as
36/// `noexport` so the `internal` attribute is correctly folded in.
37pub(crate) fn should_export_from_tags(tags: &[String], noexport: bool) -> bool {
38    let has_no_rd = crate::roxygen::has_roxygen_tag(tags, "noRd");
39    let has_internal = crate::roxygen::has_roxygen_tag(tags, "keywords internal");
40    !has_no_rd && !has_internal && !noexport
41}
42
43/// Emit the conditional S3 generic guard for a given generic name.
44///
45/// Returns an R code string (to be pushed onto a `lines: Vec<String>` with
46/// `lines.push(emit_s3_generic_guard(name))`) that creates the generic only
47/// when it doesn't already exist as a function:
48///
49/// ```r
50/// if (!exists("name", mode = "function")) {
51///   name <- function(x, ...) UseMethod("name")
52/// }
53/// ```
54///
55/// Use this for S3/vctrs class generators and trait-ABI wrappers. Do **not**
56/// use for S7 generics — those use `S7::new_generic()` / `S7::new_external_generic()`.
57pub(crate) fn emit_s3_generic_guard(name: &str) -> String {
58    format!(
59        "if (!exists(\"{name}\", mode = \"function\")) {{\n  {name} <- function(x, ...) UseMethod(\"{name}\")\n}}"
60    )
61}
62
63/// Check whether `s` is a bare R identifier (only `[A-Za-z_][A-Za-z0-9_]*`).
64pub(crate) fn is_bare_identifier(s: &str) -> bool {
65    let mut chars = s.chars();
66    match chars.next() {
67        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
68        _ => return false,
69    }
70    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
71}
72
73/// Return a `.__MX_CLASS_REF_<name>__` placeholder (for bare identifiers) so the
74/// resolver can look up the actual R class name at cdylib write time, or `name`
75/// verbatim (for namespaced / non-identifier strings).
76pub(crate) fn class_ref_or_verbatim(name: &str) -> String {
77    if is_bare_identifier(name) {
78        format!(".__MX_CLASS_REF_{name}__")
79    } else {
80        name.to_string()
81    }
82}
83
84pub(crate) use crate::match_arg_keys::{
85    choices_placeholder as match_arg_placeholder,
86    param_doc_placeholder as match_arg_param_doc_placeholder,
87};
88
89/// Build the R-param-name → @param placeholder map for a method's match_arg and
90/// choices params. Pass to `MethodDocBuilder::with_match_arg_doc_placeholders`
91/// in each class generator.
92pub(crate) fn match_arg_doc_placeholder_map(
93    c_ident: &str,
94    method: &ParsedMethod,
95) -> std::collections::HashMap<String, String> {
96    let mut out = std::collections::HashMap::new();
97    for (rust_name, attrs) in &method.method_attrs.per_param {
98        if !attrs.match_arg {
99            continue;
100        }
101        let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
102        out.insert(
103            r_name.clone(),
104            match_arg_param_doc_placeholder(c_ident, &r_name),
105        );
106    }
107    out
108}
109
110/// Effective R-formal defaults for a method.
111///
112/// Layers defaults in priority order:
113/// 1. `#[miniextendr(match_arg)]` → ALWAYS a write-time placeholder that the
114///    cdylib resolves to `c("a", "b", ...)` at package-load time. Any user-
115///    supplied `default = "X"` is consumed elsewhere (rotates X to the front
116///    of the choice list at write time) rather than overriding the formal.
117/// 2. `#[miniextendr(choices("a", "b", ...))]` → `c("a", "b", ...)` formal default.
118/// 3. User-provided `#[miniextendr(defaults(param = "..."))]` for non-match_arg
119///    params.
120fn effective_r_defaults(
121    method: &ParsedMethod,
122    c_ident: &str,
123) -> std::collections::HashMap<String, String> {
124    let mut defaults = method.param_defaults.clone();
125    // match_arg → unconditionally splice the placeholder (overriding any user
126    // default, which is captured separately for write-time rotation).
127    for (rust_name, attrs) in &method.method_attrs.per_param {
128        if !attrs.match_arg {
129            continue;
130        }
131        let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
132        defaults.insert(r_name.clone(), match_arg_placeholder(c_ident, &r_name));
133    }
134    // choices(...) → c("a", "b", ...) formal. Lower priority than user
135    // defaults (kept for back-compat on non-match_arg params).
136    for (rust_name, attrs) in &method.method_attrs.per_param {
137        if let Some(choices) = attrs.choices.as_ref() {
138            let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
139            defaults.entry(r_name).or_insert_with(|| {
140                let quoted: Vec<String> = choices.iter().map(|c| format!("\"{c}\"")).collect();
141                format!("c({})", quoted.join(", "))
142            });
143        }
144    }
145    defaults
146}
147
148/// Pre-computed context for a method, holding all data needed for R wrapper generation.
149///
150/// This struct captures the common computations performed for every method across all
151/// class systems, reducing duplicate code. It pre-formats the C wrapper name, R formal
152/// parameters (with defaults), and R call arguments so each class generator can
153/// focus on its specific formatting logic.
154pub struct MethodContext<'a> {
155    /// Reference to the parsed method metadata.
156    pub method: &'a ParsedMethod,
157    /// The C wrapper identifier string (e.g., `"C_Counter__inc"`), used in `.Call()`.
158    pub c_ident: String,
159    /// R formals string with defaults (e.g., `"value, step = 1L"`), used in
160    /// `function(...)` signatures.
161    pub params: String,
162    /// R call arguments string without defaults (e.g., `"value, step"`), used
163    /// inside `.Call()` expressions.
164    pub args: String,
165}
166
167impl<'a> MethodContext<'a> {
168    /// Create a new MethodContext for a method.
169    ///
170    /// Computes the C wrapper identifier from the method name, type name, and optional
171    /// label (for multi-impl-block disambiguation), then formats the R formals and
172    /// call arguments from the method's signature and default values.
173    pub fn new(method: &'a ParsedMethod, type_ident: &syn::Ident, label: Option<&str>) -> Self {
174        let c_ident = method.c_wrapper_ident(type_ident, label).to_string();
175        let effective_defaults = effective_r_defaults(method, &c_ident);
176        let params =
177            crate::r_wrapper_builder::build_r_formals_from_sig(&method.sig, &effective_defaults);
178        let args = crate::r_wrapper_builder::build_r_call_args_from_sig(&method.sig);
179        Self {
180            method,
181            c_ident,
182            params,
183            args,
184        }
185    }
186
187    /// Build the R-param-name → @param placeholder map for this method's
188    /// match_arg params. Pass to `MethodDocBuilder::with_match_arg_doc_placeholders`
189    /// so the cdylib write pass rewrites the placeholders into rendered choice
190    /// descriptions (#210).
191    pub fn match_arg_doc_placeholders(&self) -> std::collections::HashMap<String, String> {
192        match_arg_doc_placeholder_map(&self.c_ident, self.method)
193    }
194
195    /// Build R prelude lines that validate `match_arg` / `choices` / `several_ok`
196    /// parameters via `base::match.arg()` before the `.Call()`.
197    ///
198    /// Returns an empty vector when the method declares none. Both `match_arg`
199    /// and `choices(...)` carry their choice list as the formal default
200    /// (`c("a", "b", ...)`), so `base::match.arg(arg)` finds the list by
201    /// itself — no second arg, no C helper lookup. `match_arg` adds a
202    /// factor → character coercion in front of `match.arg`.
203    ///
204    /// Callers should include these lines in the R wrapper body after parameter
205    /// defaulting but before the `.Call()`.
206    pub fn match_arg_prelude(&self) -> Vec<String> {
207        let mut lines = Vec::new();
208
209        for (rust_name, attrs) in &self.method.method_attrs.per_param {
210            if !attrs.match_arg {
211                continue;
212            }
213            let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
214            lines.push(format!(
215                "{r_name} <- if (is.factor({r_name})) as.character({r_name}) else {r_name}"
216            ));
217            if attrs.several_ok {
218                lines.push(format!(
219                    "{r_name} <- base::match.arg({r_name}, several.ok = TRUE)"
220                ));
221            } else {
222                lines.push(format!("{r_name} <- base::match.arg({r_name})"));
223            }
224        }
225
226        for (rust_name, attrs) in &self.method.method_attrs.per_param {
227            if attrs.choices.is_none() {
228                continue;
229            }
230            let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
231            if attrs.several_ok {
232                lines.push(format!(
233                    "{r_name} <- match.arg({r_name}, several.ok = TRUE)"
234                ));
235            } else {
236                lines.push(format!("{r_name} <- match.arg({r_name})"));
237            }
238        }
239
240        lines
241    }
242
243    /// Rust-side parameter names that are validated by R's `match.arg()` and therefore
244    /// don't need `stopifnot()` preconditions generated for them.
245    fn match_arg_skip_set(&self) -> std::collections::HashSet<String> {
246        let mut s = std::collections::HashSet::new();
247        for (rust_name, attrs) in &self.method.method_attrs.per_param {
248            if attrs.match_arg || attrs.choices.is_some() {
249                s.insert(crate::r_wrapper_builder::normalize_r_arg_string(rust_name));
250            }
251        }
252        s
253    }
254
255    /// Build the `.Call()` expression for a static/constructor call.
256    pub fn static_call(&self) -> String {
257        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
258            .with_args_str(&self.args)
259            .build()
260    }
261
262    /// Build the `.Call()` expression for an instance method with `self` as ptr.
263    ///
264    /// The `self_expr` is typically "self", "private$.ptr", "x", "x@ptr", or "x@.ptr".
265    pub fn instance_call(&self, self_expr: &str) -> String {
266        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
267            .with_self(self_expr)
268            .with_args_str(&self.args)
269            .build()
270    }
271
272    /// Like [`instance_call`](Self::instance_call) but passes `.call = NULL`.
273    ///
274    /// Use for lambda dispatch sites (S7 property getter/setter) where
275    /// `match.call()` captures the S7 dispatch frame, not the user's call.
276    pub fn instance_call_null_attr(&self, self_expr: &str) -> String {
277        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
278            .null_call_attribution()
279            .with_self(self_expr)
280            .with_args_str(&self.args)
281            .build()
282    }
283
284    /// Build full R formals for instance methods (prefixing x/self parameter).
285    ///
286    /// For S3/S4/S7: `"x, <params>, ..."`
287    /// For Env/R6: `"<params>"` (self is implicit)
288    pub fn instance_formals(&self, add_self_param: bool) -> String {
289        self.instance_formals_with_dots(add_self_param, true)
290    }
291
292    /// Build full R formals for instance methods with optional dots.
293    ///
294    /// When `include_dots` is false, omits `...` from the signature.
295    /// This is used for strict generics that don't accept extra args.
296    pub fn instance_formals_with_dots(&self, add_self_param: bool, include_dots: bool) -> String {
297        if add_self_param {
298            if include_dots {
299                if self.params.is_empty() {
300                    "x, ...".to_string()
301                } else {
302                    format!("x, {}, ...", self.params)
303                }
304            } else {
305                // No dots - strict formals
306                if self.params.is_empty() {
307                    "x".to_string()
308                } else {
309                    format!("x, {}", self.params)
310                }
311            }
312        } else {
313            self.params.clone()
314        }
315    }
316
317    /// Get the generic name (uses override if present).
318    pub fn generic_name(&self) -> String {
319        self.method
320            .method_attrs
321            .generic
322            .clone()
323            .unwrap_or_else(|| self.method.ident.to_string())
324    }
325
326    /// Generate a source location comment for this method.
327    ///
328    /// Returns a string like `# Type::method (line:col)` using the method's span info.
329    /// The file name is already stated in the impl block header comment, so line:col
330    /// is sufficient to locate the method within that file.
331    pub fn source_comment(&self, type_ident: &syn::Ident) -> String {
332        let start = self.method.ident.span().start();
333        format!(
334            "# {}::{} ({}:{})",
335            type_ident,
336            self.method.ident,
337            start.line,
338            start.column + 1,
339        )
340    }
341
342    /// Check if this method uses a generic override (for existing generics like print).
343    pub fn has_generic_override(&self) -> bool {
344        self.method.method_attrs.generic.is_some()
345    }
346
347    /// Get custom class suffix if specified.
348    ///
349    /// This allows double-dispatch patterns like `vec_ptype2.my_class.my_class`
350    /// by specifying `#[miniextendr(s3(generic = "vec_ptype2", class = "my_class.my_class"))]`.
351    pub fn class_suffix(&self) -> Option<&str> {
352        self.method.method_attrs.class.as_deref()
353    }
354
355    /// Check if this method uses a custom class suffix.
356    pub fn has_class_override(&self) -> bool {
357        self.method.method_attrs.class.is_some()
358    }
359
360    /// Build R-side precondition `stopifnot()` lines for this method's parameters.
361    ///
362    /// Returns static checks for known types. Custom types not in the static table
363    /// are identified as fallback params but no R-side precheck is generated for them.
364    ///
365    /// Skips `self`/receiver parameters automatically (they are `FnArg::Receiver`) and
366    /// any parameter validated by `base::match.arg()` (via `match_arg` / `choices`) —
367    /// those already have a stronger runtime guarantee than `stopifnot(is.character(...))`.
368    pub fn precondition_checks(&self) -> Vec<String> {
369        crate::r_preconditions::build_precondition_checks(
370            &self.method.sig.inputs,
371            &self.match_arg_skip_set(),
372        )
373        .static_checks
374    }
375
376    /// Emit the 7-step method prelude into `lines`, each line prefixed with `indent`.
377    ///
378    /// The prelude is the standardised sequence that appears at the top of every
379    /// generated R method body, in order:
380    ///
381    /// 1. `r_entry` — user code injected before any checks
382    /// 2. `r_on_exit` — `on.exit(...)` cleanup
383    /// 3. `missing_prelude` — `if (missing(param)) param <- quote(expr=)` for `Missing<T>`
384    /// 4. `lifecycle_prelude` — deprecation/superseded banner (class-system-specific label)
385    /// 5. `precondition_checks` — `stopifnot(is.*(param))` for typed params
386    /// 6. `match_arg_prelude` — `base::match.arg(param)` validation
387    /// 7. `r_post_checks` — user code after all checks, before `.Call()`
388    ///
389    /// `what` is the human-readable method label passed to `lifecycle_prelude`
390    /// (e.g., `"Type.method"` for S3/S4, `"Type$method"` for Env/R6/S7).
391    /// `indent` is the per-line prefix (e.g., `"  "` for 2-space, `"      "` for 6-space).
392    pub fn emit_method_prelude(&self, lines: &mut Vec<String>, indent: &str, what: &str) {
393        let m = self.method;
394        if let Some(ref entry) = m.method_attrs.r_entry {
395            for line in entry.lines() {
396                lines.push(format!("{}{}", indent, line));
397            }
398        }
399        if let Some(ref on_exit) = m.method_attrs.r_on_exit {
400            lines.push(format!("{}{}", indent, on_exit.to_r_code()));
401        }
402        for line in self.missing_prelude() {
403            lines.push(format!("{}{}", indent, line));
404        }
405        if let Some(prelude) = m.lifecycle_prelude(what) {
406            lines.push(format!("{}{}", indent, prelude));
407        }
408        for check in self.precondition_checks() {
409            lines.push(format!("{}{}", indent, check));
410        }
411        for line in self.match_arg_prelude() {
412            lines.push(format!("{}{}", indent, line));
413        }
414        if let Some(ref post) = m.method_attrs.r_post_checks {
415            for line in post.lines() {
416                lines.push(format!("{}{}", indent, line));
417            }
418        }
419    }
420
421    /// Build `if (missing(param)) param <- quote(expr=)` prelude lines for Missing<T> parameters.
422    ///
423    /// Skips params that have a user-specified default (they get the default in formals instead).
424    pub fn missing_prelude(&self) -> Vec<String> {
425        crate::r_wrapper_builder::build_missing_prelude(
426            &self.method.sig.inputs,
427            &self.method.param_defaults,
428        )
429    }
430}
431
432/// Builder for class-level roxygen documentation header.
433///
434/// Generates the common roxygen tags that appear at the start of each class definition:
435/// - `@title` (unless user provided)
436/// - `@name` (unless user provided)
437/// - `@rdname` (unless user provided)
438/// - User-provided doc tags
439/// - `@source Generated by miniextendr...`
440/// - Class-system-specific imports
441/// - `@export` (unless user provided, `@noRd`, or internal/noexport flags)
442pub struct ClassDocBuilder<'a> {
443    /// The R-visible class name (e.g., `"Counter"`).
444    class_name: &'a str,
445    /// The Rust type identifier, used in the `@source` annotation.
446    type_ident: &'a syn::Ident,
447    /// User-provided roxygen tags extracted from doc comments.
448    doc_tags: &'a [String],
449    /// Human-readable label for the class system (e.g., `"R6"`, `"S3"`, `"Env"`),
450    /// used in the auto-generated `@title`.
451    class_system_label: &'static str,
452    /// Optional `@importFrom` tag for class-system-specific R packages
453    /// (e.g., `"@importFrom R6 R6Class"`).
454    imports: Option<String>,
455    /// When `true`, adds `@keywords internal` and suppresses `@export`.
456    /// Set by `#[miniextendr(internal)]`.
457    attr_internal: bool,
458    /// When `true`, suppresses `@export` but does not add `@keywords internal`.
459    /// Set by `#[miniextendr(noexport)]`.
460    attr_noexport: bool,
461}
462
463impl<'a> ClassDocBuilder<'a> {
464    /// Create a new ClassDocBuilder with the given class metadata.
465    ///
466    /// By default, `@export` is included unless suppressed by user tags or
467    /// the `with_export_control` method.
468    pub fn new(
469        class_name: &'a str,
470        type_ident: &'a syn::Ident,
471        doc_tags: &'a [String],
472        class_system_label: &'static str,
473    ) -> Self {
474        Self {
475            class_name,
476            type_ident,
477            doc_tags,
478            class_system_label,
479            imports: None,
480            attr_internal: false,
481            attr_noexport: false,
482        }
483    }
484
485    /// Set R package imports (e.g., "@importFrom R6 R6Class").
486    pub fn with_imports(mut self, imports: impl Into<String>) -> Self {
487        self.imports = Some(imports.into());
488        self
489    }
490
491    /// Set attribute-level internal/noexport flags from `ParsedImpl`.
492    pub fn with_export_control(mut self, internal: bool, noexport: bool) -> Self {
493        self.attr_internal = internal;
494        self.attr_noexport = noexport;
495        self
496    }
497
498    /// Build the roxygen `#' @tag` lines for the class header.
499    ///
500    /// Returns a vector of strings, each a complete roxygen comment line (e.g., `"#' @title ..."`).
501    /// Auto-generates `@title`, `@name`, and `@rdname` if not provided by the user, and
502    /// respects `@noRd` to suppress all documentation output.
503    pub fn build(&self) -> Vec<String> {
504        let has_title = crate::roxygen::has_roxygen_tag(self.doc_tags, "title");
505        let has_name = crate::roxygen::has_roxygen_tag(self.doc_tags, "name");
506        let has_rdname = crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname");
507        let has_export = crate::roxygen::has_roxygen_tag(self.doc_tags, "export");
508        let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
509        let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
510
511        let mut lines = Vec::new();
512
513        if !has_title && !has_no_rd {
514            lines.push(format!(
515                "#' @title {} {} Class",
516                self.class_name, self.class_system_label
517            ));
518        }
519        if !has_name && !has_no_rd {
520            lines.push(format!("#' @name {}", self.class_name));
521        }
522        if !has_rdname && !has_no_rd {
523            lines.push(format!("#' @rdname {}", self.class_name));
524        }
525        crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
526        if !has_no_rd {
527            lines.push(crate::roxygen::class_source_tag(self.type_ident));
528        }
529        if let Some(ref imports) = self.imports
530            && !has_no_rd
531        {
532            lines.push(format!("#' {}", imports));
533        }
534        // Inject @keywords internal if attr flag set and not already present
535        let effective_internal = has_internal || self.attr_internal;
536        if self.attr_internal && !has_internal && !has_no_rd {
537            lines.push("#' @keywords internal".to_string());
538        }
539        // Don't auto-export if @noRd, @keywords internal, or attr flags are present
540        if !has_export && !has_no_rd && !effective_internal && !self.attr_noexport {
541            lines.push("#' @export".to_string());
542        }
543
544        lines
545    }
546}
547
548/// Builder for method-level roxygen documentation.
549///
550/// Generates roxygen tags for individual methods within a class. Methods share
551/// the class's `@rdname` so they appear on the same help page. The builder handles
552/// `@name` formatting (with optional prefix like `$` for `Class$method` style)
553/// and respects `@noRd` inheritance from the parent class.
554pub struct MethodDocBuilder<'a> {
555    /// The R class name (e.g., `"Counter"`).
556    class_name: &'a str,
557    /// The Rust method name (e.g., `"inc"`).
558    method_name: &'a str,
559    /// The Rust type identifier, used in the `@source` annotation.
560    type_ident: &'a syn::Ident,
561    /// User-provided roxygen tags extracted from the method's doc comments.
562    doc_tags: &'a [String],
563    /// Optional separator between class name and method name in `@name`
564    /// (e.g., `"$"` produces `@name Counter$inc`).
565    name_prefix: Option<&'a str>,
566    /// Override for the `@name` tag when the R function name differs from the Rust
567    /// method name (e.g., for standalone S3 methods like `format.my_class`).
568    r_name_override: Option<String>,
569    /// When `true`, adds `@export` to the method (used for standalone S3/S4 generics).
570    /// Defaults to `false` because `Class$method` access does not need separate export.
571    always_export: bool,
572    /// Whether the parent class has `@noRd`. When `true`, this method emits only
573    /// `#' @noRd` and skips all other documentation tags.
574    class_has_no_rd: bool,
575    /// When `true`, convert `@param` tags into `\describe{}` blocks instead of
576    /// roxygen `@param` entries.
577    ///
578    /// Used for env-class methods where roxygen cannot infer `\usage` from
579    /// `Class$method <- function()`. Without this, `@param` tags create
580    /// `\arguments` entries with no matching `\usage`, causing R CMD check
581    /// warnings ("Documented arguments not in \\usage").
582    params_as_details: bool,
583    /// Optional comma-separated R parameter string for auto-generating `@param` tags.
584    /// When set, any parameter not already documented gets `@param name (undocumented)`.
585    r_params: Option<&'a str>,
586    /// When `true`, filter out `@param` tags from the doc_tags before pushing.
587    ///
588    /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
589    /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
590    /// Including `@param` tags would create "Documented arguments not in \\usage" warnings.
591    suppress_params: bool,
592    /// Map of R-param-name → write-time doc placeholder for match_arg parameters.
593    ///
594    /// When the auto-generated `@param` line would otherwise say `(undocumented)`,
595    /// a match_arg'd param emits the placeholder instead, which the cdylib's
596    /// write-time pass replaces with a rendered choice description (#210).
597    match_arg_doc_placeholders: Option<&'a std::collections::HashMap<String, String>>,
598}
599
600impl<'a> MethodDocBuilder<'a> {
601    /// Create a new MethodDocBuilder with default settings.
602    ///
603    /// By default, `always_export` is `false` because methods accessed via `Class$method`
604    /// should not be exported directly -- only the class env and standalone S3 methods
605    /// need `@export`.
606    pub fn new(
607        class_name: &'a str,
608        method_name: &'a str,
609        type_ident: &'a syn::Ident,
610        doc_tags: &'a [String],
611    ) -> Self {
612        Self {
613            class_name,
614            method_name,
615            type_ident,
616            doc_tags,
617            name_prefix: None,
618            r_name_override: None,
619            always_export: false,
620            class_has_no_rd: false,
621            params_as_details: false,
622            r_params: None,
623            suppress_params: false,
624            match_arg_doc_placeholders: None,
625        }
626    }
627
628    /// Supply a map from R-param-name to a write-time doc placeholder for
629    /// match_arg'd params. When the auto-generated `@param` line would otherwise
630    /// say `(undocumented)`, the placeholder is emitted instead and the cdylib
631    /// write pass rewrites it to a rendered choice description. See #210.
632    pub fn with_match_arg_doc_placeholders(
633        mut self,
634        placeholders: &'a std::collections::HashMap<String, String>,
635    ) -> Self {
636        self.match_arg_doc_placeholders = Some(placeholders);
637        self
638    }
639
640    /// Set a prefix for the @name tag (e.g., "$" for "Class$method").
641    pub fn with_name_prefix(mut self, prefix: &'a str) -> Self {
642        self.name_prefix = Some(prefix);
643        self
644    }
645
646    /// Override the @name tag with a custom R function name.
647    ///
648    /// Use this when the R function name differs from the Rust method name
649    /// (e.g., for standalone S3/S4/S7 static methods like `s3counter_default_counter`).
650    pub fn with_r_name(mut self, r_name: String) -> Self {
651        self.r_name_override = Some(r_name);
652        self
653    }
654
655    /// Set whether the parent class has @noRd.
656    ///
657    /// When true, skips @name, @rdname, @source tags and adds @noRd instead.
658    pub fn with_class_no_rd(mut self, class_has_no_rd: bool) -> Self {
659        self.class_has_no_rd = class_has_no_rd;
660        self
661    }
662
663    /// Convert `@param` tags to inline `\describe{}` blocks instead of roxygen `@param`.
664    ///
665    /// Used for env-class methods where roxygen can't infer `\usage` from `Class$method <- function()`.
666    /// Without this, `@param` tags create `\arguments` entries with no matching `\usage`,
667    /// causing R CMD check warnings ("Documented arguments not in \\usage").
668    pub fn with_params_as_details(mut self) -> Self {
669        self.params_as_details = true;
670        self
671    }
672
673    /// Set the method's formal parameter names (comma-separated R params string).
674    ///
675    /// When set, auto-generates `@param name (undocumented)` for any parameter
676    /// not already covered by a user `@param` tag. Skips `self`, `.ptr`, and
677    /// `...` parameters.
678    pub fn with_r_params(mut self, params: &'a str) -> Self {
679        self.r_params = Some(params);
680        self
681    }
682
683    /// Suppress `@param` tags from user doc comments.
684    ///
685    /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
686    /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
687    pub fn with_suppress_params(mut self) -> Self {
688        self.suppress_params = true;
689        self
690    }
691
692    /// Build the roxygen `#' @tag` lines for the method.
693    ///
694    /// Returns a vector of strings, each a complete roxygen comment line. If the parent
695    /// class has `@noRd`, returns only `["#' @noRd"]`. Otherwise generates `@name`,
696    /// `@rdname`, `@source`, and optionally `@export` tags, plus any user-provided tags.
697    pub fn build(&self) -> Vec<String> {
698        let mut lines = Vec::new();
699
700        // If parent class has @noRd, skip all documentation and just add @noRd
701        if self.class_has_no_rd {
702            lines.push("#' @noRd".to_string());
703            return lines;
704        }
705
706        if !self.doc_tags.is_empty() {
707            if self.params_as_details {
708                // For env-class: emit non-@param tags normally, convert @param to \describe
709                let (param_tags, other_tags): (Vec<_>, Vec<_>) = self
710                    .doc_tags
711                    .iter()
712                    .partition(|t| t.trim_start().starts_with("@param "));
713                let other_refs: Vec<&str> = other_tags.iter().map(|s| s.as_str()).collect();
714                crate::roxygen::push_roxygen_tags_str(&mut lines, &other_refs);
715                if !param_tags.is_empty() {
716                    // Only add blank separator if the previous line isn't @title
717                    // (roxygen2 treats blank lines after @title as multi-paragraph titles)
718                    let last_is_title = lines.last().is_some_and(|l| l.contains("@title"));
719                    if !last_is_title {
720                        lines.push("#'".to_string());
721                    }
722                    lines.push("#' \\describe{".to_string());
723                    for tag in &param_tags {
724                        if let Some(rest) = tag.trim_start().strip_prefix("@param ") {
725                            let mut parts = rest.splitn(2, char::is_whitespace);
726                            let name = parts.next().unwrap_or("");
727                            let desc = parts.next().unwrap_or("");
728                            lines.push(format!("#'   \\item{{\\code{{{name}}}}}{{{desc}}}"));
729                        }
730                    }
731                    lines.push("#' }".to_string());
732                }
733            } else if self.suppress_params {
734                // Filter out @param tags — they would create "Documented arguments
735                // not in \usage" warnings for S4/S7 methods.
736                let filtered: Vec<&str> = self
737                    .doc_tags
738                    .iter()
739                    .filter(|t| {
740                        !t.trim_start()
741                            .strip_prefix('@')
742                            .is_some_and(|rest| rest.starts_with("param"))
743                    })
744                    .map(|s| s.as_str())
745                    .collect();
746                crate::roxygen::push_roxygen_tags_str(&mut lines, &filtered);
747            } else {
748                crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
749            }
750        }
751
752        // Auto-generate @param for undocumented method parameters
753        if let Some(params) = self.r_params {
754            for param in params.split(", ").filter(|p| !p.is_empty()) {
755                let param_name = param.split('=').next().unwrap_or(param).trim();
756                if param_name == ".ptr" || param_name == "..." || param_name == "self" {
757                    continue;
758                }
759                let already_documented = self
760                    .doc_tags
761                    .iter()
762                    .any(|t| t.starts_with(&format!("@param {}", param_name)));
763                if !already_documented {
764                    // match_arg'd params get a placeholder the cdylib write-pass
765                    // replaces with the rendered choice description (#210).
766                    let body = self
767                        .match_arg_doc_placeholders
768                        .and_then(|m| m.get(param_name))
769                        .map(|s| s.as_str())
770                        .unwrap_or("(undocumented)");
771                    lines.push(format!("#' @param {} {}", param_name, body));
772                }
773            }
774        }
775
776        if !crate::roxygen::has_roxygen_tag(self.doc_tags, "name") {
777            let name = if let Some(ref r_name) = self.r_name_override {
778                r_name.clone()
779            } else if let Some(prefix) = self.name_prefix {
780                format!("{}{}{}", self.class_name, prefix, self.method_name)
781            } else {
782                self.method_name.to_string()
783            };
784            lines.push(format!("#' @name {}", name));
785        }
786
787        if !crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname") {
788            lines.push(format!("#' @rdname {}", self.class_name));
789        }
790
791        lines.push(format!(
792            "#' @source Generated by miniextendr from `{}::{}`",
793            self.type_ident, self.method_name
794        ));
795
796        let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
797        let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
798        // Don't auto-export if @noRd or @keywords internal is present
799        if self.always_export
800            && !crate::roxygen::has_roxygen_tag(self.doc_tags, "export")
801            && !has_no_rd
802            && !has_internal
803        {
804            lines.push("#' @export".to_string());
805        }
806
807        lines
808    }
809}
810
811/// Extension trait for `ParsedImpl` to iterate over methods as [`MethodContext`].
812///
813/// Provides convenience methods that wrap `ParsedImpl`'s method iterators,
814/// automatically constructing a `MethodContext` for each method. This avoids
815/// repeating the `MethodContext::new(m, type_ident, label)` boilerplate in
816/// every class system generator.
817pub trait ParsedImplExt {
818    /// Create a `MethodContext` for the constructor method, if one exists.
819    fn constructor_context(&self) -> Option<MethodContext<'_>>;
820
821    /// Iterate over all instance methods (public + private + active) as `MethodContext`.
822    fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
823
824    /// Iterate over static (non-receiver) methods as `MethodContext`.
825    fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
826
827    /// Iterate over public instance methods as `MethodContext` (for R6 `public` list).
828    fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
829
830    /// Iterate over private instance methods as `MethodContext` (for R6 `private` list).
831    fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
832
833    /// Iterate over active binding methods as `MethodContext` (for R6 `active` list).
834    fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
835}
836
837impl ParsedImplExt for ParsedImpl {
838    fn constructor_context(&self) -> Option<MethodContext<'_>> {
839        self.constructor()
840            .map(|m| MethodContext::new(m, &self.type_ident, self.label()))
841    }
842
843    fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
844        let type_ident = &self.type_ident;
845        let label = self.label();
846        self.instance_methods()
847            .map(move |m| MethodContext::new(m, type_ident, label))
848    }
849
850    fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
851        let type_ident = &self.type_ident;
852        let label = self.label();
853        self.static_methods()
854            .map(move |m| MethodContext::new(m, type_ident, label))
855    }
856
857    fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
858        let type_ident = &self.type_ident;
859        let label = self.label();
860        self.public_instance_methods()
861            .map(move |m| MethodContext::new(m, type_ident, label))
862    }
863
864    fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
865        let type_ident = &self.type_ident;
866        let label = self.label();
867        self.private_instance_methods()
868            .map(move |m| MethodContext::new(m, type_ident, label))
869    }
870
871    fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
872        let type_ident = &self.type_ident;
873        let label = self.label();
874        self.active_instance_methods()
875            .map(move |m| MethodContext::new(m, type_ident, label))
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    #[test]
882    fn test_method_context_static_call_no_args() {
883        // This is a unit test for the static_call method
884        // We'd need a mock ParsedMethod to test fully, but we can test the logic
885        let call = ".Call(C_Test, .call = match.call())";
886        assert!(call.contains(".Call"));
887    }
888}