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