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.
92///
93/// Takes the per-param attribute map directly (rather than `&ParsedMethod`) so
94/// it's shared by both the inherent-impl (`MethodContext`) and trait-impl
95/// (`TraitMethodContext`, `miniextendr_impl_trait/method_context.rs`) paths.
96pub(crate) fn match_arg_doc_placeholder_map(
97    c_ident: &str,
98    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
99) -> std::collections::HashMap<String, String> {
100    let mut out = std::collections::HashMap::new();
101    for (rust_name, attrs) in per_param {
102        if !attrs.match_arg {
103            continue;
104        }
105        let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
106        out.insert(
107            r_name.clone(),
108            match_arg_param_doc_placeholder(c_ident, &r_name),
109        );
110    }
111    out
112}
113
114/// Build R prelude lines that validate `match_arg` / `choices` / `several_ok`
115/// parameters via `base::match.arg()` before the `.Call()`.
116///
117/// Returns an empty vector when the method declares none. Both `match_arg`
118/// and `choices(...)` carry their choice list as the formal default
119/// (`c("a", "b", ...)`), so `base::match.arg(arg)` finds the list by
120/// itself — no second arg, no C helper lookup. `match_arg` adds a
121/// factor → character coercion in front of `match.arg`.
122///
123/// Shared by `MethodContext::match_arg_prelude` (inherent impls) and
124/// `TraitMethodContext::match_arg_prelude` (trait impls) — see
125/// `audit/2026-07-03-dogfooding-macros-codegen.md` finding #1 (trait methods
126/// previously had no match_arg support at all).
127pub(crate) fn build_match_arg_prelude(
128    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
129) -> Vec<String> {
130    let mut lines = Vec::new();
131
132    for (rust_name, attrs) in per_param {
133        if !attrs.match_arg {
134            continue;
135        }
136        let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
137        lines.push(format!(
138            "{r_name} <- if (is.factor({r_name})) as.character({r_name}) else {r_name}"
139        ));
140        if attrs.several_ok {
141            lines.push(format!(
142                "{r_name} <- base::match.arg({r_name}, several.ok = TRUE)"
143            ));
144        } else {
145            lines.push(format!("{r_name} <- base::match.arg({r_name})"));
146        }
147    }
148
149    for (rust_name, attrs) in per_param {
150        if attrs.choices.is_none() {
151            continue;
152        }
153        let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
154        if attrs.several_ok {
155            lines.push(format!(
156                "{r_name} <- match.arg({r_name}, several.ok = TRUE)"
157            ));
158        } else {
159            lines.push(format!("{r_name} <- match.arg({r_name})"));
160        }
161    }
162
163    lines
164}
165
166/// Rust-side parameter names that are validated by R's `match.arg()` and
167/// therefore don't need `stopifnot()` preconditions generated for them.
168/// Shared by `MethodContext` and `TraitMethodContext`.
169pub(crate) fn match_arg_skip_set(
170    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
171) -> std::collections::HashSet<String> {
172    let mut s = std::collections::HashSet::new();
173    for (rust_name, attrs) in per_param {
174        if attrs.match_arg || attrs.choices.is_some() {
175            s.insert(crate::r_wrapper_builder::normalize_r_arg_string(rust_name));
176        }
177    }
178    s
179}
180
181/// Build R-side precondition `stopifnot()` lines for a parameter list, given
182/// its match_arg/choices per-param map and whether `coerce` is active for the
183/// whole method.
184///
185/// Neither impl methods nor trait methods carry a per-param `coerce` flag
186/// (only function-wide `coerce`, see `ParsedMethod::per_param` docs), so
187/// `coerce_params` is always empty here. Shared by
188/// `MethodContext::precondition_checks` and
189/// `TraitMethodContext::precondition_checks`.
190pub(crate) fn build_method_precondition_checks(
191    inputs: &syn::punctuated::Punctuated<syn::FnArg, syn::Token![,]>,
192    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
193    coerce_all: bool,
194) -> Vec<String> {
195    let opts = crate::r_preconditions::PreconditionOptions {
196        coerce_all,
197        coerce_params: std::collections::HashSet::new(),
198    };
199    crate::r_preconditions::build_precondition_checks(inputs, &match_arg_skip_set(per_param), &opts)
200        .static_checks
201}
202
203/// Effective R-formal defaults for a method.
204///
205/// Layers defaults in priority order:
206/// 1. `#[miniextendr(match_arg)]` → ALWAYS a write-time placeholder that the
207///    cdylib resolves to `c("a", "b", ...)` at package-load time. Any user-
208///    supplied `default = "X"` is consumed elsewhere (rotates X to the front
209///    of the choice list at write time) rather than overriding the formal.
210/// 2. `#[miniextendr(choices("a", "b", ...))]` → `c("a", "b", ...)` formal default.
211/// 3. User-provided `#[miniextendr(defaults(param = "..."))]` for non-match_arg
212///    params.
213///
214/// This formal default is load-bearing for `match.arg()`, not just cosmetic:
215/// `base::match.arg(arg)` (no explicit `choices=`) reads the choice list from
216/// the *formal default* of the calling function's `arg` parameter — a
217/// `match_arg`/`choices` param with no formal default makes `match.arg()`
218/// fail with "argument is missing, with no default" even when the caller
219/// passed a value. Shared by `MethodContext::new` (inherent impls) and
220/// `TraitMethodContext::new` (trait impls, `miniextendr_impl_trait/method_context.rs`).
221pub(crate) fn effective_r_defaults(
222    param_defaults: &std::collections::HashMap<String, String>,
223    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
224    c_ident: &str,
225) -> std::collections::HashMap<String, String> {
226    let mut defaults = param_defaults.clone();
227    // match_arg → unconditionally splice the placeholder (overriding any user
228    // default, which is captured separately for write-time rotation).
229    for (rust_name, attrs) in per_param {
230        if !attrs.match_arg {
231            continue;
232        }
233        let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
234        defaults.insert(r_name.clone(), match_arg_placeholder(c_ident, &r_name));
235    }
236    // choices(...) → c("a", "b", ...) formal. Lower priority than user
237    // defaults (kept for back-compat on non-match_arg params).
238    for (rust_name, attrs) in per_param {
239        if let Some(choices) = attrs.choices.as_ref() {
240            let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
241            defaults.entry(r_name).or_insert_with(|| {
242                let quoted: Vec<String> = choices.iter().map(|c| format!("\"{c}\"")).collect();
243                format!("c({})", quoted.join(", "))
244            });
245        }
246    }
247    defaults
248}
249
250/// Pre-computed context for a method, holding all data needed for R wrapper generation.
251///
252/// This struct captures the common computations performed for every method across all
253/// class systems, reducing duplicate code. It pre-formats the C wrapper name, R formal
254/// parameters (with defaults), and R call arguments so each class generator can
255/// focus on its specific formatting logic.
256pub struct MethodContext<'a> {
257    /// Reference to the parsed method metadata.
258    pub method: &'a ParsedMethod,
259    /// The C wrapper identifier string (e.g., `"C_Counter__inc"`), used in `.Call()`.
260    pub c_ident: String,
261    /// R formals string with defaults (e.g., `"value, step = 1L"`), used in
262    /// `function(...)` signatures.
263    pub params: String,
264    /// R call arguments string without defaults (e.g., `"value, step"`), used
265    /// inside `.Call()` expressions.
266    pub args: String,
267}
268
269impl<'a> MethodContext<'a> {
270    /// Create a new MethodContext for a method.
271    ///
272    /// Computes the C wrapper identifier from the method name, type name, and optional
273    /// label (for multi-impl-block disambiguation), then formats the R formals and
274    /// call arguments from the method's signature and default values.
275    pub fn new(method: &'a ParsedMethod, type_ident: &syn::Ident, label: Option<&str>) -> Self {
276        let c_ident = method.c_wrapper_ident(type_ident, label).to_string();
277        let effective_defaults = effective_r_defaults(
278            &method.param_defaults,
279            &method.method_attrs.per_param,
280            &c_ident,
281        );
282        let params =
283            crate::r_wrapper_builder::build_r_formals_from_sig(&method.sig, &effective_defaults);
284        let args = crate::r_wrapper_builder::build_r_call_args_from_sig(&method.sig);
285        Self {
286            method,
287            c_ident,
288            params,
289            args,
290        }
291    }
292
293    /// Build the R-param-name → @param placeholder map for this method's
294    /// match_arg params. Pass to `MethodDocBuilder::with_match_arg_doc_placeholders`
295    /// so the cdylib write pass rewrites the placeholders into rendered choice
296    /// descriptions (#210).
297    pub fn match_arg_doc_placeholders(&self) -> std::collections::HashMap<String, String> {
298        match_arg_doc_placeholder_map(&self.c_ident, &self.method.method_attrs.per_param)
299    }
300
301    /// Build R prelude lines that validate `match_arg` / `choices` / `several_ok`
302    /// parameters via `base::match.arg()` before the `.Call()`.
303    ///
304    /// Returns an empty vector when the method declares none. Both `match_arg`
305    /// and `choices(...)` carry their choice list as the formal default
306    /// (`c("a", "b", ...)`), so `base::match.arg(arg)` finds the list by
307    /// itself — no second arg, no C helper lookup. `match_arg` adds a
308    /// factor → character coercion in front of `match.arg`.
309    ///
310    /// Callers should include these lines in the R wrapper body after parameter
311    /// defaulting but before the `.Call()`.
312    pub fn match_arg_prelude(&self) -> Vec<String> {
313        build_match_arg_prelude(&self.method.method_attrs.per_param)
314    }
315
316    /// Build the `.Call()` expression for a static/constructor call.
317    pub fn static_call(&self) -> String {
318        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
319            .with_args_str(&self.args)
320            .build()
321    }
322
323    /// Build the `.Call()` expression for an instance method with `self` as ptr.
324    ///
325    /// The `self_expr` is typically "self", "private$.ptr", "x", "x@ptr", or "x@.ptr".
326    pub fn instance_call(&self, self_expr: &str) -> String {
327        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
328            .with_self(self_expr)
329            .with_args_str(&self.args)
330            .build()
331    }
332
333    /// Like [`instance_call`](Self::instance_call) but passes `.call = NULL`.
334    ///
335    /// Use for lambda dispatch sites (S7 property getter/setter) where
336    /// `match.call()` captures the S7 dispatch frame, not the user's call.
337    pub fn instance_call_null_attr(&self, self_expr: &str) -> String {
338        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
339            .null_call_attribution()
340            .with_self(self_expr)
341            .with_args_str(&self.args)
342            .build()
343    }
344
345    /// Build full R formals for instance methods (prefixing x/self parameter).
346    ///
347    /// For S3/S4/S7: `"x, <params>, ..."`
348    /// For Env/R6: `"<params>"` (self is implicit)
349    pub fn instance_formals(&self, add_self_param: bool) -> String {
350        self.instance_formals_with_dots(add_self_param, true)
351    }
352
353    /// Build full R formals for instance methods with optional dots.
354    ///
355    /// When `include_dots` is false, omits `...` from the signature.
356    /// This is used for strict generics that don't accept extra args.
357    pub fn instance_formals_with_dots(&self, add_self_param: bool, include_dots: bool) -> String {
358        if add_self_param {
359            if include_dots {
360                if self.params.is_empty() {
361                    "x, ...".to_string()
362                } else {
363                    format!("x, {}, ...", self.params)
364                }
365            } else {
366                // No dots - strict formals
367                if self.params.is_empty() {
368                    "x".to_string()
369                } else {
370                    format!("x, {}", self.params)
371                }
372            }
373        } else {
374            self.params.clone()
375        }
376    }
377
378    /// Build instance formals with a custom receiver name (default is `x`).
379    ///
380    /// Used by the S7 per-class fast-path shortcut (#949), whose receiver is
381    /// named `self` to mirror the property dispatch lambdas, rather than the
382    /// `x` used by the S7 generic.
383    pub fn instance_formals_with_receiver(&self, receiver: &str, include_dots: bool) -> String {
384        let tail = if include_dots { ", ..." } else { "" };
385        if self.params.is_empty() {
386            format!("{receiver}{tail}")
387        } else {
388            format!("{receiver}, {}{tail}", self.params)
389        }
390    }
391
392    /// Get the generic name (uses override if present).
393    pub fn generic_name(&self) -> String {
394        self.method
395            .method_attrs
396            .generic
397            .clone()
398            .unwrap_or_else(|| self.method.ident.to_string())
399    }
400
401    /// Generate a source location comment for this method.
402    ///
403    /// Returns a string like `# Type::method (line:col)` using the method's span info.
404    /// The file name is already stated in the impl block header comment, so line:col
405    /// is sufficient to locate the method within that file.
406    pub fn source_comment(&self, type_ident: &syn::Ident) -> String {
407        let start = self.method.ident.span().start();
408        format!(
409            "# {}::{} ({}:{})",
410            type_ident,
411            self.method.ident,
412            start.line,
413            start.column + 1,
414        )
415    }
416
417    /// Check if this method uses a generic override (for existing generics like print).
418    pub fn has_generic_override(&self) -> bool {
419        self.method.method_attrs.generic.is_some()
420    }
421
422    /// Get custom class suffix if specified.
423    ///
424    /// This allows double-dispatch patterns like `vec_ptype2.my_class.my_class`
425    /// by specifying `#[miniextendr(s3(generic = "vec_ptype2", class = "my_class.my_class"))]`.
426    pub fn class_suffix(&self) -> Option<&str> {
427        self.method.method_attrs.class.as_deref()
428    }
429
430    /// Check if this method uses a custom class suffix.
431    pub fn has_class_override(&self) -> bool {
432        self.method.method_attrs.class.is_some()
433    }
434
435    /// Build R-side precondition `stopifnot()` lines for this method's parameters.
436    ///
437    /// Returns static checks for known types. Custom types not in the static table
438    /// are identified as fallback params but no R-side precheck is generated for them.
439    ///
440    /// Skips `self`/receiver parameters automatically (they are `FnArg::Receiver`) and
441    /// any parameter validated by `base::match.arg()` (via `match_arg` / `choices`) —
442    /// those already have a stronger runtime guarantee than `stopifnot(is.character(...))`.
443    pub fn precondition_checks(&self) -> Vec<String> {
444        // A coerced integer-element vector reads via `&[i32]` (INTSXP-only), so
445        // its precondition tightens to `is.integer` (#616). Impl methods carry
446        // coerce at method level (`method_attrs.coerce`, equivalent to
447        // `coerce_all`); there is no per-param coerce on the impl path (see
448        // ParsedMethod::per_param docs).
449        build_method_precondition_checks(
450            &self.method.sig.inputs,
451            &self.method.method_attrs.per_param,
452            self.method.method_attrs.coerce,
453        )
454    }
455
456    /// Emit the 6-step method prelude into `lines`, each line prefixed with `indent`.
457    ///
458    /// The prelude is the standardised sequence that appears at the top of every
459    /// generated R method body, in order:
460    ///
461    /// 1. `r_entry` — user code injected before any checks
462    /// 2. `r_on_exit` — `on.exit(...)` cleanup
463    /// 3. `lifecycle_prelude` — deprecation/superseded banner (class-system-specific label)
464    /// 4. `precondition_checks` — `stopifnot(is.*(param))` for typed params
465    /// 5. `match_arg_prelude` — `base::match.arg(param)` validation
466    /// 6. `r_post_checks` — user code after all checks, before `.Call()`
467    ///
468    /// (`Missing<T>` forwarding is not a prelude step: it lives inline in the
469    /// `.Call()` args — see `build_call_args_vec` — because a binding of the
470    /// missing sentinel errors on lookup.)
471    ///
472    /// `what` is the human-readable method label passed to `lifecycle_prelude`
473    /// (e.g., `"Type.method"` for S3/S4, `"Type$method"` for Env/R6/S7).
474    /// `indent` is the per-line prefix (e.g., `"  "` for 2-space, `"      "` for 6-space).
475    pub fn emit_method_prelude(&self, lines: &mut Vec<String>, indent: &str, what: &str) {
476        let m = self.method;
477        if let Some(ref entry) = m.method_attrs.r_entry {
478            for line in entry.lines() {
479                lines.push(format!("{}{}", indent, line));
480            }
481        }
482        if let Some(ref on_exit) = m.method_attrs.r_on_exit {
483            lines.push(format!("{}{}", indent, on_exit.to_r_code()));
484        }
485        if let Some(prelude) = m.lifecycle_prelude(what) {
486            lines.push(format!("{}{}", indent, prelude));
487        }
488        for check in self.precondition_checks() {
489            lines.push(format!("{}{}", indent, check));
490        }
491        for line in self.match_arg_prelude() {
492            lines.push(format!("{}{}", indent, line));
493        }
494        if let Some(ref post) = m.method_attrs.r_post_checks {
495            for line in post.lines() {
496                lines.push(format!("{}{}", indent, line));
497            }
498        }
499    }
500}
501
502/// Builder for class-level roxygen documentation header.
503///
504/// Generates the common roxygen tags that appear at the start of each class definition:
505/// - `@title` (unless user provided)
506/// - `@name` (unless user provided)
507/// - `@rdname` (unless user provided)
508/// - User-provided doc tags
509/// - `@source Generated by miniextendr...`
510/// - Class-system-specific imports
511/// - `@export` (unless user provided, `@noRd`, or internal/noexport flags)
512pub struct ClassDocBuilder<'a> {
513    /// The R-visible class name (e.g., `"Counter"`).
514    class_name: &'a str,
515    /// The Rust type identifier, used in the `@source` annotation.
516    type_ident: &'a syn::Ident,
517    /// User-provided roxygen tags extracted from doc comments.
518    doc_tags: &'a [String],
519    /// Human-readable label for the class system (e.g., `"R6"`, `"S3"`, `"Env"`),
520    /// used in the auto-generated `@title`.
521    class_system_label: &'static str,
522    /// Optional `@importFrom` tag for class-system-specific R packages
523    /// (e.g., `"@importFrom R6 R6Class"`).
524    imports: Option<String>,
525    /// When `true`, adds `@keywords internal` and suppresses `@export`.
526    /// Set by `#[miniextendr(internal)]`.
527    attr_internal: bool,
528    /// When `true`, suppresses `@export` but does not add `@keywords internal`.
529    /// Set by `#[miniextendr(noexport)]`.
530    attr_noexport: bool,
531}
532
533impl<'a> ClassDocBuilder<'a> {
534    /// Create a new ClassDocBuilder with the given class metadata.
535    ///
536    /// By default, `@export` is included unless suppressed by user tags or
537    /// the `with_export_control` method.
538    pub fn new(
539        class_name: &'a str,
540        type_ident: &'a syn::Ident,
541        doc_tags: &'a [String],
542        class_system_label: &'static str,
543    ) -> Self {
544        Self {
545            class_name,
546            type_ident,
547            doc_tags,
548            class_system_label,
549            imports: None,
550            attr_internal: false,
551            attr_noexport: false,
552        }
553    }
554
555    /// Set R package imports (e.g., "@importFrom R6 R6Class").
556    pub fn with_imports(mut self, imports: impl Into<String>) -> Self {
557        self.imports = Some(imports.into());
558        self
559    }
560
561    /// Set attribute-level internal/noexport flags from `ParsedImpl`.
562    pub fn with_export_control(mut self, internal: bool, noexport: bool) -> Self {
563        self.attr_internal = internal;
564        self.attr_noexport = noexport;
565        self
566    }
567
568    /// Build the roxygen `#' @tag` lines for the class header.
569    ///
570    /// Returns a vector of strings, each a complete roxygen comment line (e.g., `"#' @title ..."`).
571    /// Auto-generates `@title`, `@name`, and `@rdname` if not provided by the user, and
572    /// respects `@noRd` to suppress all documentation output.
573    pub fn build(&self) -> Vec<String> {
574        let has_title = crate::roxygen::has_roxygen_tag(self.doc_tags, "title");
575        let has_name = crate::roxygen::has_roxygen_tag(self.doc_tags, "name");
576        let has_rdname = crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname");
577        let has_export = crate::roxygen::has_roxygen_tag(self.doc_tags, "export");
578        let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
579        let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
580        let effective_internal = has_internal || self.attr_internal;
581
582        // `noexport` (without `internal`) must produce no Rd contribution at all —
583        // no alias, no usage entry, nothing on a shared page — distinct from
584        // `internal`, which stays documented under `\keyword{internal}`. Fold a
585        // plain `noexport` into the same suppression gate as a user-written
586        // `@noRd`. `internal` wins if both flags are set on the same impl block
587        // (mirrors the standalone-fn `#[miniextendr(internal)]` precedence, where
588        // `internal` + `noexport` together is a compile error).
589        let suppress_rd = has_no_rd || (self.attr_noexport && !effective_internal);
590
591        let mut lines = Vec::new();
592
593        if suppress_rd && !has_no_rd {
594            lines.push("#' @noRd".to_string());
595        }
596
597        if !has_title && !suppress_rd {
598            lines.push(format!(
599                "#' @title {} {} Class",
600                self.class_name, self.class_system_label
601            ));
602        }
603        if !has_name && !suppress_rd {
604            lines.push(format!("#' @name {}", self.class_name));
605        }
606        if !has_rdname && !suppress_rd {
607            lines.push(format!("#' @rdname {}", self.class_name));
608        }
609        crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
610        if !suppress_rd {
611            lines.push(crate::roxygen::class_source_tag(self.type_ident));
612        }
613        if let Some(ref imports) = self.imports
614            && !suppress_rd
615        {
616            lines.push(format!("#' {}", imports));
617        }
618        // Inject @keywords internal if attr flag set and not already present
619        if self.attr_internal && !has_internal && !suppress_rd {
620            lines.push("#' @keywords internal".to_string());
621        }
622        // Don't auto-export if @noRd, @keywords internal, or attr flags are present
623        if !has_export && !suppress_rd && !effective_internal && !self.attr_noexport {
624            lines.push("#' @export".to_string());
625        }
626
627        lines
628    }
629}
630
631/// Builder for method-level roxygen documentation.
632///
633/// Generates roxygen tags for individual methods within a class. Methods share
634/// the class's `@rdname` so they appear on the same help page. The builder handles
635/// `@name` formatting (with optional prefix like `$` for `Class$method` style)
636/// and respects `@noRd` inheritance from the parent class.
637pub struct MethodDocBuilder<'a> {
638    /// The R class name (e.g., `"Counter"`).
639    class_name: &'a str,
640    /// The Rust method name (e.g., `"inc"`).
641    method_name: &'a str,
642    /// The Rust type identifier, used in the `@source` annotation.
643    type_ident: &'a syn::Ident,
644    /// User-provided roxygen tags extracted from the method's doc comments.
645    doc_tags: &'a [String],
646    /// Optional separator between class name and method name in `@name`
647    /// (e.g., `"$"` produces `@name Counter$inc`).
648    name_prefix: Option<&'a str>,
649    /// Override for the `@name` tag when the R function name differs from the Rust
650    /// method name (e.g., for standalone S3 methods like `format.my_class`).
651    r_name_override: Option<String>,
652    /// When `true`, adds `@export` to the method (used for standalone S3/S4 generics).
653    /// Defaults to `false` because `Class$method` access does not need separate export.
654    always_export: bool,
655    /// Whether the parent class has `@noRd`. When `true`, this method emits only
656    /// `#' @noRd` and skips all other documentation tags.
657    class_has_no_rd: bool,
658    /// When `true`, convert `@param` tags into `\describe{}` blocks instead of
659    /// roxygen `@param` entries.
660    ///
661    /// Used for env-class methods where roxygen cannot infer `\usage` from
662    /// `Class$method <- function()`. Without this, `@param` tags create
663    /// `\arguments` entries with no matching `\usage`, causing R CMD check
664    /// warnings ("Documented arguments not in \\usage").
665    params_as_details: bool,
666    /// Optional comma-separated R parameter string for auto-generating `@param` tags.
667    /// When set, any parameter not already documented gets `@param name (undocumented)`.
668    r_params: Option<&'a str>,
669    /// When `true`, filter out `@param` tags from the doc_tags before pushing.
670    ///
671    /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
672    /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
673    /// Including `@param` tags would create "Documented arguments not in \\usage" warnings.
674    suppress_params: bool,
675    /// Map of R-param-name → write-time doc placeholder for match_arg parameters.
676    ///
677    /// When the auto-generated `@param` line would otherwise say `(undocumented)`,
678    /// a match_arg'd param emits the placeholder instead, which the cdylib's
679    /// write-time pass replaces with a rendered choice description (#210).
680    match_arg_doc_placeholders: Option<&'a std::collections::HashMap<String, String>>,
681}
682
683impl<'a> MethodDocBuilder<'a> {
684    /// Create a new MethodDocBuilder with default settings.
685    ///
686    /// By default, `always_export` is `false` because methods accessed via `Class$method`
687    /// should not be exported directly -- only the class env and standalone S3 methods
688    /// need `@export`.
689    pub fn new(
690        class_name: &'a str,
691        method_name: &'a str,
692        type_ident: &'a syn::Ident,
693        doc_tags: &'a [String],
694    ) -> Self {
695        Self {
696            class_name,
697            method_name,
698            type_ident,
699            doc_tags,
700            name_prefix: None,
701            r_name_override: None,
702            always_export: false,
703            class_has_no_rd: false,
704            params_as_details: false,
705            r_params: None,
706            suppress_params: false,
707            match_arg_doc_placeholders: None,
708        }
709    }
710
711    /// Supply a map from R-param-name to a write-time doc placeholder for
712    /// match_arg'd params. When the auto-generated `@param` line would otherwise
713    /// say `(undocumented)`, the placeholder is emitted instead and the cdylib
714    /// write pass rewrites it to a rendered choice description. See #210.
715    pub fn with_match_arg_doc_placeholders(
716        mut self,
717        placeholders: &'a std::collections::HashMap<String, String>,
718    ) -> Self {
719        self.match_arg_doc_placeholders = Some(placeholders);
720        self
721    }
722
723    /// Set a prefix for the @name tag (e.g., "$" for "Class$method").
724    pub fn with_name_prefix(mut self, prefix: &'a str) -> Self {
725        self.name_prefix = Some(prefix);
726        self
727    }
728
729    /// Override the @name tag with a custom R function name.
730    ///
731    /// Use this when the R function name differs from the Rust method name
732    /// (e.g., for standalone S3/S4/S7 static methods like `s3counter_default_counter`).
733    pub fn with_r_name(mut self, r_name: String) -> Self {
734        self.r_name_override = Some(r_name);
735        self
736    }
737
738    /// Set whether the parent class has @noRd.
739    ///
740    /// When true, skips @name, @rdname, @source tags and adds @noRd instead.
741    pub fn with_class_no_rd(mut self, class_has_no_rd: bool) -> Self {
742        self.class_has_no_rd = class_has_no_rd;
743        self
744    }
745
746    /// Convert `@param` tags to inline `\describe{}` blocks instead of roxygen `@param`.
747    ///
748    /// Used for env-class methods where roxygen can't infer `\usage` from `Class$method <- function()`.
749    /// Without this, `@param` tags create `\arguments` entries with no matching `\usage`,
750    /// causing R CMD check warnings ("Documented arguments not in \\usage").
751    pub fn with_params_as_details(mut self) -> Self {
752        self.params_as_details = true;
753        self
754    }
755
756    /// Set the method's formal parameter names (comma-separated R params string).
757    ///
758    /// When set, auto-generates `@param name (undocumented)` for any parameter
759    /// not already covered by a user `@param` tag. Skips `self`, `.ptr`, and
760    /// `...` parameters.
761    pub fn with_r_params(mut self, params: &'a str) -> Self {
762        self.r_params = Some(params);
763        self
764    }
765
766    /// Suppress `@param` tags from user doc comments.
767    ///
768    /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
769    /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
770    pub fn with_suppress_params(mut self) -> Self {
771        self.suppress_params = true;
772        self
773    }
774
775    /// Build the roxygen `#' @tag` lines for the method.
776    ///
777    /// Returns a vector of strings, each a complete roxygen comment line. If the parent
778    /// class has `@noRd`, returns only `["#' @noRd"]`. Otherwise generates `@name`,
779    /// `@rdname`, `@source`, and optionally `@export` tags, plus any user-provided tags.
780    pub fn build(&self) -> Vec<String> {
781        let mut lines = Vec::new();
782
783        // If parent class has @noRd, skip all documentation and just add @noRd
784        if self.class_has_no_rd {
785            lines.push("#' @noRd".to_string());
786            return lines;
787        }
788
789        if !self.doc_tags.is_empty() {
790            if self.params_as_details {
791                // For env-class: emit non-@param tags normally, convert @param to \describe
792                let (param_tags, other_tags): (Vec<_>, Vec<_>) = self
793                    .doc_tags
794                    .iter()
795                    .partition(|t| t.trim_start().starts_with("@param "));
796                let other_refs: Vec<&str> = other_tags.iter().map(|s| s.as_str()).collect();
797                crate::roxygen::push_roxygen_tags_str(&mut lines, &other_refs);
798                if !param_tags.is_empty() {
799                    // Only add blank separator if the previous line isn't @title
800                    // (roxygen2 treats blank lines after @title as multi-paragraph titles)
801                    let last_is_title = lines.last().is_some_and(|l| l.contains("@title"));
802                    if !last_is_title {
803                        lines.push("#'".to_string());
804                    }
805                    lines.push("#' \\describe{".to_string());
806                    for tag in &param_tags {
807                        if let Some(rest) = tag.trim_start().strip_prefix("@param ") {
808                            let mut parts = rest.splitn(2, char::is_whitespace);
809                            let name = parts.next().unwrap_or("");
810                            let desc = parts.next().unwrap_or("");
811                            lines.push(format!("#'   \\item{{\\code{{{name}}}}}{{{desc}}}"));
812                        }
813                    }
814                    lines.push("#' }".to_string());
815                }
816            } else if self.suppress_params {
817                // Filter out @param tags — they would create "Documented arguments
818                // not in \usage" warnings for S4/S7 methods.
819                let filtered: Vec<&str> = self
820                    .doc_tags
821                    .iter()
822                    .filter(|t| {
823                        !t.trim_start()
824                            .strip_prefix('@')
825                            .is_some_and(|rest| rest.starts_with("param"))
826                    })
827                    .map(|s| s.as_str())
828                    .collect();
829                crate::roxygen::push_roxygen_tags_str(&mut lines, &filtered);
830            } else {
831                crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
832            }
833        }
834
835        // Auto-generate @param for undocumented method parameters. Split on
836        // top-level commas only — a naive `split(", ")` shreds a
837        // `mode = c("fast", "slow")` default into a bogus `"slow")` formal,
838        // which surfaces as a spurious @param and an R CMD check warning.
839        if let Some(params) = self.r_params {
840            for param in crate::roxygen::split_r_formals(params) {
841                let param_name = crate::roxygen::formal_name(param);
842                if param_name == ".ptr" || param_name == "..." || param_name == "self" {
843                    continue;
844                }
845                let already_documented = self
846                    .doc_tags
847                    .iter()
848                    .any(|t| t.starts_with(&format!("@param {}", param_name)));
849                if !already_documented {
850                    // match_arg'd params get a placeholder the cdylib write-pass
851                    // replaces with the rendered choice description (#210).
852                    let body = self
853                        .match_arg_doc_placeholders
854                        .and_then(|m| m.get(param_name))
855                        .map(|s| s.as_str())
856                        .unwrap_or("(undocumented)");
857                    lines.push(format!("#' @param {} {}", param_name, body));
858                }
859            }
860        }
861
862        if !crate::roxygen::has_roxygen_tag(self.doc_tags, "name") {
863            let name = if let Some(ref r_name) = self.r_name_override {
864                r_name.clone()
865            } else if let Some(prefix) = self.name_prefix {
866                format!("{}{}{}", self.class_name, prefix, self.method_name)
867            } else {
868                self.method_name.to_string()
869            };
870            lines.push(format!("#' @name {}", name));
871        }
872
873        if !crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname") {
874            lines.push(format!("#' @rdname {}", self.class_name));
875        }
876
877        lines.push(format!(
878            "#' @source Generated by miniextendr from `{}::{}`",
879            self.type_ident, self.method_name
880        ));
881
882        let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
883        let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
884        // Don't auto-export if @noRd or @keywords internal is present
885        if self.always_export
886            && !crate::roxygen::has_roxygen_tag(self.doc_tags, "export")
887            && !has_no_rd
888            && !has_internal
889        {
890            lines.push("#' @export".to_string());
891        }
892
893        lines
894    }
895}
896
897/// Extension trait for `ParsedImpl` to iterate over methods as [`MethodContext`].
898///
899/// Provides convenience methods that wrap `ParsedImpl`'s method iterators,
900/// automatically constructing a `MethodContext` for each method. This avoids
901/// repeating the `MethodContext::new(m, type_ident, label)` boilerplate in
902/// every class system generator.
903pub trait ParsedImplExt {
904    /// Create a `MethodContext` for the constructor method, if one exists.
905    fn constructor_context(&self) -> Option<MethodContext<'_>>;
906
907    /// Iterate over all instance methods (public + private + active) as `MethodContext`.
908    fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
909
910    /// Iterate over static (non-receiver) methods as `MethodContext`.
911    fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
912
913    /// Iterate over public instance methods as `MethodContext` (for R6 `public` list).
914    fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
915
916    /// Iterate over private instance methods as `MethodContext` (for R6 `private` list).
917    fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
918
919    /// Iterate over active binding methods as `MethodContext` (for R6 `active` list).
920    fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
921}
922
923impl ParsedImplExt for ParsedImpl {
924    fn constructor_context(&self) -> Option<MethodContext<'_>> {
925        self.constructor()
926            .map(|m| MethodContext::new(m, &self.type_ident, self.label()))
927    }
928
929    fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
930        let type_ident = &self.type_ident;
931        let label = self.label();
932        self.instance_methods()
933            .map(move |m| MethodContext::new(m, type_ident, label))
934    }
935
936    fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
937        let type_ident = &self.type_ident;
938        let label = self.label();
939        self.static_methods()
940            .map(move |m| MethodContext::new(m, type_ident, label))
941    }
942
943    fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
944        let type_ident = &self.type_ident;
945        let label = self.label();
946        self.public_instance_methods()
947            .map(move |m| MethodContext::new(m, type_ident, label))
948    }
949
950    fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
951        let type_ident = &self.type_ident;
952        let label = self.label();
953        self.private_instance_methods()
954            .map(move |m| MethodContext::new(m, type_ident, label))
955    }
956
957    fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
958        let type_ident = &self.type_ident;
959        let label = self.label();
960        self.active_instance_methods()
961            .map(move |m| MethodContext::new(m, type_ident, label))
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    use super::ClassDocBuilder;
968
969    #[test]
970    fn test_method_context_static_call_no_args() {
971        // This is a unit test for the static_call method
972        // We'd need a mock ParsedMethod to test fully, but we can test the logic
973        let call = ".Call(C_Test, .call = match.call())";
974        assert!(call.contains(".Call"));
975    }
976
977    /// Audit A10: a class-level `#[miniextendr(noexport)]` (without `internal`)
978    /// must produce no Rd contribution at all — no `@title`/`@name`/`@rdname`/
979    /// `@export` — same as a user-written `@noRd`. Before the fix, `noexport`
980    /// only suppressed `@export`, leaving the class fully documented (with an
981    /// alias) minus the export line.
982    #[test]
983    fn test_class_noexport_suppresses_all_roxygen() {
984        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
985        let doc_tags: Vec<String> = vec![];
986        let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, "R6")
987            .with_export_control(false, true)
988            .build();
989        let joined = lines.join("\n");
990
991        assert!(
992            lines.iter().any(|l| l == "#' @noRd"),
993            "noexport should emit @noRd, got:\n{}",
994            joined
995        );
996        assert!(
997            !joined.contains("@title") && !joined.contains("@name") && !joined.contains("@rdname"),
998            "noexport should suppress @title/@name/@rdname entirely, got:\n{}",
999            joined
1000        );
1001        assert!(
1002            !joined.contains("@export"),
1003            "noexport should suppress @export, got:\n{}",
1004            joined
1005        );
1006    }
1007
1008    /// Companion: `#[miniextendr(internal)]` keeps the class documented (under
1009    /// `@keywords internal`) — it still contributes `@title`/`@name`/`@rdname`
1010    /// so it lands on a real help page, just unexported.
1011    #[test]
1012    fn test_class_internal_still_documented() {
1013        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
1014        let doc_tags: Vec<String> = vec![];
1015        let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, "R6")
1016            .with_export_control(true, false)
1017            .build();
1018        let joined = lines.join("\n");
1019
1020        assert!(
1021            !lines.iter().any(|l| l == "#' @noRd"),
1022            "internal should NOT emit @noRd (stays documented), got:\n{}",
1023            joined
1024        );
1025        assert!(
1026            joined.contains("@keywords internal"),
1027            "internal should add @keywords internal, got:\n{}",
1028            joined
1029        );
1030        assert!(
1031            joined.contains("@title") && joined.contains("@name") && joined.contains("@rdname"),
1032            "internal should still emit @title/@name/@rdname, got:\n{}",
1033            joined
1034        );
1035        assert!(
1036            !joined.contains("#' @export"),
1037            "internal should suppress @export, got:\n{}",
1038            joined
1039        );
1040    }
1041
1042    /// Neither flag set: normal fully-documented, exported class.
1043    #[test]
1044    fn test_class_no_flags_fully_documented_and_exported() {
1045        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
1046        let doc_tags: Vec<String> = vec![];
1047        let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, "R6")
1048            .with_export_control(false, false)
1049            .build();
1050        let joined = lines.join("\n");
1051
1052        assert!(!joined.contains("@noRd"));
1053        assert!(!joined.contains("@keywords internal"));
1054        assert!(
1055            joined.contains("@title") && joined.contains("@name") && joined.contains("@rdname")
1056        );
1057        assert!(joined.contains("#' @export"));
1058    }
1059}