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::{CLASS_DOCS_MARKER, ParsedImpl, ParsedMethod, class_default};
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    // The assignment target must be an R symbol; operator generics such as
120    // `%custom%` need backticks there, while the string literals keep the bare
121    // name (#1475).
122    let symbol = crate::naming::r_def_name(name);
123    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("if (!base::exists(\"{0}\", mode = \"function\")) {{\n  {1} <- function(x, ...) UseMethod(\"{0}\")\n}} else if (local({{ .mx_gen <- base::get(\"{0}\", mode = \"function\"); !(is.primitive(.mx_gen) || isTRUE(utils::isS3stdGeneric(.mx_gen)) || methods::isGeneric(\"{0}\") || inherits(.mx_gen, \"S7_generic\")) }})) {{\n  # `{0}` 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 `{0}.default` binding\n  # would trip roxygen2\'s dynamic S3 scan (warn_missing_s3_exports), and a\n  # static NAMESPACE S3method({0}, default) would break package load\n  # whenever this branch does not fire.\n  .mx_shadow_default <- local({{\n    .mx_masked <- base::get(\"{0}\", mode = \"function\")\n    function(x, ...) .mx_masked(x, ...)\n  }})\n  {1} <- function(x, ...) UseMethod(\"{0}\")\n  base::registerS3method(\"{0}\", \"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.",
                name, symbol))
    })format!(
124        "if (!base::exists(\"{name}\", mode = \"function\")) {{\n  {symbol} <- 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  {symbol} <- 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."
125    )
126}
127
128/// Check whether `s` is a bare R identifier (only `[A-Za-z_][A-Za-z0-9_]*`).
129pub(crate) fn is_bare_identifier(s: &str) -> bool {
130    let mut chars = s.chars();
131    match chars.next() {
132        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
133        _ => return false,
134    }
135    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
136}
137
138/// Return a `.__MX_CLASS_REF_<name>__` placeholder (for bare identifiers) so the
139/// resolver can look up the actual R class name at wrapper write time, or `name`
140/// verbatim (for namespaced / non-identifier strings).
141pub(crate) fn class_ref_or_verbatim(name: &str) -> String {
142    if is_bare_identifier(name) {
143        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(".__MX_CLASS_REF_{0}__", name))
    })format!(".__MX_CLASS_REF_{name}__")
144    } else {
145        name.to_string()
146    }
147}
148
149pub(crate) use crate::match_arg_keys::{
150    choices_placeholder as match_arg_placeholder,
151    param_doc_placeholder as match_arg_param_doc_placeholder,
152};
153
154/// Build the R-formal → auto-generated `@param` text map for a method's
155/// choice params: the write-time placeholder for a `match_arg` param (the
156/// wrapper writer renders it from the enum's `MatchArg::CHOICES`, #210), the
157/// literal `One of "a", "b".` line for a `choices(...)` param
158/// ([`ParamAttrs::literal_choices_doc`](crate::miniextendr_fn::ParamAttrs::literal_choices_doc),
159/// the same text a standalone function gets). Pass to
160/// `MethodDocBuilder::with_choice_param_docs` in each class generator.
161///
162/// Takes the per-param attribute map directly (rather than `&ParsedMethod`) so
163/// it's shared by both the inherent-impl (`MethodContext`) and trait-impl
164/// (`TraitMethodContext`, `miniextendr_impl_trait/method_context.rs`) paths.
165pub(crate) fn choice_param_doc_map(
166    c_ident: &str,
167    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
168) -> std::collections::HashMap<String, String> {
169    let formals = crate::r_wrapper_builder::Formals::from_param_attrs(per_param);
170    let mut out = std::collections::HashMap::new();
171    for (rust_name, attrs) in per_param {
172        let doc = if attrs.match_arg {
173            match_arg_param_doc_placeholder(c_ident, &crate::match_arg_keys::param_key(rust_name))
174        } else if let Some(doc) = attrs.literal_choices_doc() {
175            doc
176        } else {
177            continue;
178        };
179        out.insert(formals.of(rust_name), doc);
180    }
181    out
182}
183
184/// Build R prelude lines that validate `match_arg` / `choices` / `several_ok`
185/// parameters before the `.Call()`.
186///
187/// Returns an empty vector when the method declares none. Every form is one
188/// statement from `CallAttribution::match_arg_statement` with the method's
189/// own attribution: the strict scalar helper (`NULL` and the formal default
190/// select the first choice, a factor is read as its labels), the `Option<T>`
191/// form that skips it for `NULL` (#1473), and the strict `several_ok` helper
192/// (every element must match, `NULL` selects all; #1472). An `Either` layer,
193/// over a scalar or a `several_ok` list (#1612), guards either helper with
194/// `is.character(x) || is.factor(x)`, so everything else reaches the `R`
195/// arm; `Missing` adds `!missing(x)` (#1551), and for a single choice
196/// `!identical(x, <choices>)` (#2152). For `match_arg` the
197/// choice list is the write-time placeholder (`c_ident` keys it, the same one
198/// `effective_r_defaults` puts in the formal); for `choices(...)` it is the
199/// literal.
200///
201/// The `match_arg` statements come first, then the `choices(...)` ones, each
202/// group in signature order (`inputs`), as in a standalone function's wrapper.
203///
204/// Shared by `MethodContext::match_arg_prelude` (inherent impls) and
205/// `TraitMethodContext::match_arg_prelude` (trait impls) — see
206/// `audit/2026-07-03-dogfooding-macros-codegen.md` finding #1 (trait methods
207/// previously had no match_arg support at all).
208pub(crate) fn build_match_arg_prelude(
209    inputs: &syn::punctuated::Punctuated<syn::FnArg, ::syn::token::Commasyn::Token![,]>,
210    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
211    c_ident: &str,
212    attribution: crate::r_wrapper_builder::CallAttribution,
213) -> Vec<String> {
214    use crate::miniextendr_fn::per_param_in_signature_order;
215    let formals = crate::r_wrapper_builder::Formals::from_param_attrs(per_param);
216    let mut lines = Vec::new();
217
218    for (rust_name, attrs) in per_param_in_signature_order(inputs, per_param) {
219        if !attrs.match_arg {
220            continue;
221        }
222        let key = crate::match_arg_keys::param_key(rust_name);
223        let placeholder = match_arg_placeholder(c_ident, &key);
224        let aliases = crate::match_arg_keys::aliases_placeholder(c_ident, &key);
225        lines.push(attribution.match_arg_statement(
226            &formals.of(rust_name),
227            &placeholder,
228            Some(&aliases),
229            attrs,
230        ));
231    }
232
233    for (rust_name, attrs) in per_param_in_signature_order(inputs, per_param) {
234        let Some(choices) = attrs.choices.as_ref() else {
235            continue;
236        };
237        let r_name = formals.of(rust_name);
238        let quoted: Vec<String> = choices.iter().map(|c| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("\"{0}\"", c))
    })format!("\"{c}\"")).collect();
239        let choices_expr = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("c({0})", quoted.join(", ")))
    })format!("c({})", quoted.join(", "));
240        lines.push(attribution.match_arg_statement(&r_name, &choices_expr, None, attrs));
241    }
242
243    lines
244}
245
246/// The R formals of the parameters that are validated by R's `match.arg()`
247/// and therefore don't need type-check preconditions generated for them.
248/// Shared by `MethodContext` and `TraitMethodContext`.
249pub(crate) fn match_arg_skip_set(
250    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
251) -> std::collections::HashSet<String> {
252    let formals = crate::r_wrapper_builder::Formals::from_param_attrs(per_param);
253    per_param
254        .iter()
255        .filter(|(_, attrs)| attrs.match_arg || attrs.choices.is_some())
256        .map(|(rust_name, _)| formals.of(rust_name))
257        .collect()
258}
259
260/// Build the R-side precondition guard lines for a parameter list, given
261/// its per-param map (match_arg/choices skips, `inherits` / `not_inherits` /
262/// `no_na` checks, each parameter's own `preconditions` decision), whether
263/// `coerce` is active
264/// for the whole method, and the method's and impl block's `preconditions` /
265/// `no_preconditions` (`method_level`, `impl_level`). Each parameter keeps
266/// its type-derived checks as `crate::r_preconditions::resolve_type_checks`
267/// decides, under the crate default and the `no-preconditions-default`
268/// feature; the named checks stay either way.
269///
270/// Neither impl methods nor trait methods carry a per-param `coerce` flag
271/// (only function-wide `coerce`, see `ParsedMethod::per_param` docs), so
272/// `coerce_params` is always empty here. `call` is the call every failing
273/// guard is attributed to (`PreconditionOutput::guards`): `None` for the
274/// method's own frame, `environment()` for an S3 method (#1851). Shared by
275/// `MethodContext::precondition_checks`,
276/// `TraitMethodContext::precondition_checks` and the R6 active-binding
277/// setter.
278pub(crate) fn build_method_precondition_checks(
279    inputs: &syn::punctuated::Punctuated<syn::FnArg, ::syn::token::Commasyn::Token![,]>,
280    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
281    coerce_all: bool,
282    method_level: Option<bool>,
283    impl_level: Option<bool>,
284    call: Option<&str>,
285) -> Vec<String> {
286    let formals = crate::r_wrapper_builder::Formals::from_param_attrs(per_param);
287    let opts = crate::r_preconditions::PreconditionOptions {
288        coerce_all,
289        coerce_params: std::collections::HashSet::new(),
290        explicit: crate::miniextendr_fn::explicit_checks_by_r_name(per_param),
291        unchecked: crate::r_preconditions::unchecked_params(
292            inputs,
293            &formals,
294            |name| per_param.get(name).and_then(|a| a.preconditions),
295            method_level,
296            impl_level,
297            crate::crate_config::preconditions_default(),
298        ),
299        formals,
300    };
301    crate::r_preconditions::build_precondition_checks(inputs, &match_arg_skip_set(per_param), &opts)
302        .guards(call)
303}
304
305/// Effective R-formal defaults for a method.
306///
307/// Layers defaults in priority order:
308/// 1. `#[miniextendr(match_arg)]` → ALWAYS a write-time placeholder that the
309///    wrapper writer resolves to `c("a", "b", ...)`. Any user-
310///    supplied `default = "X"` is consumed elsewhere (rotates X to the front
311///    of the choice list at write time) rather than overriding the formal.
312/// 2. `#[miniextendr(choices("a", "b", ...))]` → `c("a", "b", ...)` formal default.
313/// 3. User-provided `#[miniextendr(defaults(param = "..."))]` for non-match_arg
314///    params.
315///
316/// The `Option<T>` form of a choice parameter gets `NULL` instead (#1473),
317/// and a `no_default(p)` one no default at all (#1828; see
318/// [`ParamAttrs::choice_formal`](crate::miniextendr_fn::ParamAttrs::choice_formal)).
319/// A choice formal is documentation: the prelude
320/// ([`build_match_arg_prelude`]) passes the choice list to the helpers, which
321/// never read it off the formal (#1552). Shared by `MethodContext::new`
322/// (inherent impls) and `TraitMethodContext::new` (trait impls,
323/// `miniextendr_impl_trait/method_context.rs`).
324///
325/// `param_defaults` is keyed by Rust name, as `defaults(p = "...")` names
326/// the parameter; the result is keyed by R formal (#1842), as
327/// [`RArgumentBuilder::with_defaults`](crate::r_wrapper_builder::RArgumentBuilder::with_defaults)
328/// reads it.
329pub(crate) fn effective_r_defaults(
330    param_defaults: &std::collections::HashMap<String, String>,
331    per_param: &std::collections::HashMap<String, crate::miniextendr_fn::ParamAttrs>,
332    c_ident: &str,
333) -> std::collections::HashMap<String, String> {
334    let formals = crate::r_wrapper_builder::Formals::from_param_attrs(per_param);
335    let mut defaults = formals.rekey(param_defaults.clone());
336    // match_arg → unconditionally splice the placeholder (overriding any user
337    // default, which is captured separately for write-time rotation).
338    for (rust_name, attrs) in per_param {
339        if !attrs.match_arg {
340            continue;
341        }
342        let key = crate::match_arg_keys::param_key(rust_name);
343        // `Option<T>` (#1473): the formal is NULL (no choice); `no_default`
344        // (#1828, refused with `defaults(p = ...)`): none. The prelude spells
345        // the choices out through the placeholder either way.
346        if let Some(default) = attrs.choice_formal(&match_arg_placeholder(c_ident, &key)) {
347            defaults.insert(formals.of(rust_name), default);
348        }
349    }
350    // choices(...) → c("a", "b", ...) formal (NULL for the `Option<T>` form,
351    // none under `no_default`). Lower priority than user defaults (kept for
352    // back-compat on non-match_arg params).
353    for (rust_name, attrs) in per_param {
354        let Some(choices) = attrs.choices.as_ref() else {
355            continue;
356        };
357        let quoted: Vec<String> = choices.iter().map(|c| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("\"{0}\"", c))
    })format!("\"{c}\"")).collect();
358        if let Some(default) = attrs.choice_formal(&::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("c({0})", quoted.join(", ")))
    })format!("c({})", quoted.join(", "))) {
359            defaults.entry(formals.of(rust_name)).or_insert(default);
360        }
361    }
362    defaults
363}
364
365/// Pre-computed context for a method, holding all data needed for R wrapper generation.
366///
367/// This struct captures the common computations performed for every method across all
368/// class systems, reducing duplicate code. It pre-formats the C wrapper name, R formal
369/// parameters (with defaults), and R call arguments so each class generator can
370/// focus on its specific formatting logic.
371pub struct MethodContext<'a> {
372    /// Reference to the parsed method metadata.
373    pub method: &'a ParsedMethod,
374    /// The C wrapper identifier string (e.g., `"C_Counter__inc"`), used in `.Call()`.
375    pub c_ident: String,
376    /// R formals string with defaults (e.g., `"value, step = 1L"`), used in
377    /// `function(...)` signatures.
378    pub params: String,
379    /// R call arguments string without defaults (e.g., `"value, step"`), used
380    /// inside `.Call()` expressions.
381    pub args: String,
382    /// The impl block's `preconditions` / `no_preconditions`
383    /// (`ImplAttrs::preconditions`): below the method's own and each
384    /// parameter's, above the crate default (see
385    /// `build_method_precondition_checks`).
386    pub impl_preconditions: Option<bool>,
387    /// The call the method's conditions report (`CallAttribution`): the
388    /// method's own `sys.call()` by default; an S3 method's generator sets
389    /// `CallAttribution::Generic`, the frame resolved to the generic's call
390    /// on a raise (#1851). It decides the `.call` of `instance_call`, the
391    /// call the R-side checks raise with and the raise fallback.
392    pub call_attribution: crate::r_wrapper_builder::CallAttribution,
393}
394
395impl<'a> MethodContext<'a> {
396    /// Create a new MethodContext for a method.
397    ///
398    /// Computes the C wrapper identifier from the method name and type name,
399    /// then formats the R formals and call arguments from the method's
400    /// signature and default values. The
401    /// impl block says nothing about the R-side checks; use
402    /// [`MethodContext::with_impl_preconditions`] to inherit its
403    /// `preconditions` / `no_preconditions`.
404    pub fn new(method: &'a ParsedMethod, type_ident: &syn::Ident) -> Self {
405        let c_ident = method.c_wrapper_ident(type_ident).to_string();
406        let effective_defaults = effective_r_defaults(
407            &method.param_defaults,
408            &method.method_attrs.per_param,
409            &c_ident,
410        );
411        let arg_builder = crate::r_wrapper_builder::RArgumentBuilder::new(&method.sig.inputs)
412            .with_formals(method.formals())
413            .with_defaults(effective_defaults)
414            .with_absent_formals(crate::miniextendr_fn::absent_formals_by_r_name(
415                &method.method_attrs.per_param,
416                &c_ident,
417            ));
418        let params = arg_builder.build_formals();
419        let args = arg_builder.build_call_args();
420        Self {
421            method,
422            c_ident,
423            params,
424            args,
425            impl_preconditions: None,
426            call_attribution: crate::r_wrapper_builder::CallAttribution::Wrapper,
427        }
428    }
429
430    /// Set the `preconditions` decision inherited from the surrounding
431    /// `ImplAttrs`. Returns `self` so callers can chain on top of
432    /// `MethodContext::new`.
433    pub fn with_impl_preconditions(mut self, impl_preconditions: Option<bool>) -> Self {
434        self.impl_preconditions = impl_preconditions;
435        self
436    }
437
438    /// Set the call the method's conditions report (see
439    /// [`MethodContext::call_attribution`]).
440    pub fn with_call_attribution(
441        mut self,
442        call_attribution: crate::r_wrapper_builder::CallAttribution,
443    ) -> Self {
444        self.call_attribution = call_attribution;
445        self
446    }
447
448    /// Build the R-param-name → `@param` text map for this method's
449    /// `match_arg` / `choices` params ([`choice_param_doc_map`]) and its
450    /// `Missing<..>` params with a default
451    /// ([`missing_default_param_docs`](crate::miniextendr_fn::missing_default_param_docs),
452    /// #2161). Pass to `MethodDocBuilder::with_choice_param_docs`.
453    pub fn choice_param_docs(&self) -> std::collections::HashMap<String, String> {
454        let per_param = &self.method.method_attrs.per_param;
455        let mut docs = choice_param_doc_map(&self.c_ident, per_param);
456        docs.extend(crate::miniextendr_fn::missing_default_param_docs(
457            &self.method.sig.inputs,
458            per_param,
459            &self.method.param_defaults,
460            &self.method.formals(),
461        ));
462        docs
463    }
464
465    /// Build R prelude lines that validate `match_arg` / `choices` / `several_ok`
466    /// parameters via `base::match.arg()` before the `.Call()`.
467    ///
468    /// Returns an empty vector when the method declares none. Both `match_arg`
469    /// and `choices(...)` carry their choice list as the formal default
470    /// (`c("a", "b", ...)`), so `base::match.arg(arg)` finds the list by
471    /// itself — no second arg, no C helper lookup. `match_arg` adds a
472    /// factor → character coercion in front of `match.arg`.
473    ///
474    /// Callers should include these lines in the R wrapper body after parameter
475    /// defaulting but before the `.Call()`.
476    pub fn match_arg_prelude(&self) -> Vec<String> {
477        build_match_arg_prelude(
478            &self.method.sig.inputs,
479            &self.method.method_attrs.per_param,
480            &self.c_ident,
481            self.call_attribution,
482        )
483    }
484
485    /// Build the `.Call()` expression for a static/constructor call.
486    pub fn static_call(&self) -> String {
487        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
488            .with_args_str(&self.args)
489            .build()
490    }
491
492    /// Build the `.Call()` expression for an instance method with `self` as ptr.
493    ///
494    /// The `self_expr` is typically "self", "private$.ptr", "x", "x@ptr", or "x@.ptr".
495    /// The `.call` is the context's [`MethodContext::call_attribution`].
496    pub fn instance_call(&self, self_expr: &str) -> String {
497        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
498            .with_call_attribution(self.call_attribution)
499            .with_self(self_expr)
500            .with_args_str(&self.args)
501            .build()
502    }
503
504    /// Like [`instance_call`](Self::instance_call) but passes `.call = NULL`.
505    ///
506    /// Use for lambda dispatch sites (S7 property getter/setter) where
507    /// `sys.call()` names the S7 dispatch frame, not the user's call.
508    pub fn instance_call_null_attr(&self, self_expr: &str) -> String {
509        crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
510            .null_call_attribution()
511            .with_self(self_expr)
512            .with_args_str(&self.args)
513            .build()
514    }
515
516    /// Full R formals of a `<generic>.<Class>` S3 method: `"x, <params>, ..."`,
517    /// except that for a replacement generic (a name ending in `<-`, such as
518    /// `[[<-` or `names<-`) the dispatch `...` goes before the last formal,
519    /// `"x, i, ..., value"`. `R CMD check` (`tools::checkReplaceFuns()`)
520    /// requires a replacement method's last formal to be `value`, and the
521    /// impl-block check (`check_s3_replacement_methods`) makes sure it is
522    /// (#1853). A method that takes `&Dots` keeps its own order.
523    pub fn s3_method_formals(&self) -> String {
524        let receiver = self.method.method_attrs.receiver.as_deref().unwrap_or("x");
525        let dots = !self.method.method_attrs.no_dispatch_dots;
526        if !dots || self.method.has_dots || !self.generic_name().ends_with("<-") {
527            return self.instance_formals_with_receiver(receiver, dots);
528        }
529        let mut formals = crate::roxygen::split_r_formals(&self.params);
530        formals.retain(|formal| !formal.is_empty());
531        let last = formals.pop();
532        let mut out = ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [receiver]))vec![receiver];
533        out.extend(formals);
534        out.push("...");
535        out.extend(last);
536        out.join(", ")
537    }
538
539    /// Build instance formals with a custom receiver name (default is `x`).
540    ///
541    /// Used by S3/S4 generic overrides and the S7 per-class shortcut.
542    pub fn instance_formals_with_receiver(&self, receiver: &str, include_dots: bool) -> String {
543        instance_formals(
544            &self.params,
545            receiver,
546            include_dots && !self.method.has_dots,
547        )
548    }
549
550    /// Get the generic name (uses override if present).
551    pub fn generic_name(&self) -> String {
552        // Explicit `generic = ".."` wins; otherwise the R-facing method name
553        // (`r_name` / `postfix` / Rust ident) doubles as the generic, so a
554        // renamed S3/S7 instance method dispatches under its R name.
555        self.method
556            .method_attrs
557            .generic
558            .clone()
559            .unwrap_or_else(|| self.method.r_method_name())
560    }
561
562    /// Generate a source location comment for this method.
563    ///
564    /// Returns a string like `# Type::method (line:col)` using the method's span info.
565    /// The file name is already stated in the impl block header comment, so line:col
566    /// is sufficient to locate the method within that file.
567    pub fn source_comment(&self, type_ident: &syn::Ident) -> String {
568        let start = self.method.ident.span().start();
569        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("# {0}::{1} ({2}:{3})", type_ident,
                self.method.ident, start.line, start.column + 1))
    })format!(
570            "# {}::{} ({}:{})",
571            type_ident,
572            self.method.ident,
573            start.line,
574            start.column + 1,
575        )
576    }
577
578    /// Check if this method uses a generic override (for existing generics like print).
579    pub fn has_generic_override(&self) -> bool {
580        self.method.method_attrs.generic.is_some()
581    }
582
583    /// Get custom class suffix if specified.
584    ///
585    /// This allows double-dispatch patterns like `vec_ptype2.my_class.my_class`
586    /// by specifying `#[miniextendr(s3(generic = "vec_ptype2", class = "my_class.my_class"))]`.
587    pub fn class_suffix(&self) -> Option<&str> {
588        self.method.method_attrs.class.as_deref()
589    }
590
591    /// Check if this method uses a custom class suffix.
592    pub fn has_class_override(&self) -> bool {
593        self.method.method_attrs.class.is_some()
594    }
595
596    /// Build the R-side precondition guard lines for this method's parameters.
597    ///
598    /// Returns static checks for known types. Custom types not in the static table
599    /// are identified as fallback params but no R-side precheck is generated for them.
600    ///
601    /// Skips `self`/receiver parameters automatically (they are `FnArg::Receiver`) and
602    /// any parameter validated by `base::match.arg()` (via `match_arg` / `choices`) —
603    /// those already have a stronger runtime guarantee than an `is.character()` check.
604    ///
605    /// Each parameter keeps or drops its type-derived checks as its own
606    /// spelling, the method's, the impl block's, the crate default and the
607    /// feature decide ([`build_method_precondition_checks`]); the
608    /// per-parameter `inherits(...)` / `not_inherits(...)` / `no_na(...)`
609    /// checks stay.
610    pub fn precondition_checks(&self) -> Vec<String> {
611        // A coerced integer-element vector reads via `&[i32]` (INTSXP-only), so
612        // its precondition tightens to `is.integer` (#616). Impl methods carry
613        // coerce at method level (`method_attrs.coerce`, equivalent to
614        // `coerce_all`); there is no per-param coerce on the impl path (see
615        // ParsedMethod::per_param docs).
616        build_method_precondition_checks(
617            &self.method.sig.inputs,
618            &self.method.method_attrs.per_param,
619            self.method.method_attrs.coerce,
620            self.method.method_attrs.preconditions,
621            self.impl_preconditions,
622            self.call_attribution.r_check_call(),
623        )
624    }
625
626    /// Emit the 7-step method prelude into `lines`, each line prefixed with `indent`.
627    ///
628    /// The prelude is the standardised sequence that appears at the top of every
629    /// generated R method body, in order:
630    ///
631    /// 1. `r_entry` — user code injected before any checks
632    /// 2. `r_on_exit` — `on.exit(...)` cleanup
633    /// 3. `lifecycle_prelude` — deprecation/superseded banner (class-system-specific label)
634    /// 4. the `...` check of `dots = empty | forward(..)` (#2157,
635    ///    [`DotsPolicy::r_check`](crate::dots_policy::DotsPolicy::r_check)),
636    ///    which reads the wrapper's `formals` (the R formals list of the
637    ///    `function()` the prelude opens) and is skipped when they have no `...`
638    /// 5. `precondition_checks` — one `isTRUE()` guard per check on typed params
639    /// 6. `match_arg_prelude` — `base::match.arg(param)` validation
640    /// 7. `r_post_checks` — user code after all checks, before `.Call()`
641    ///
642    /// (`Missing<T>` forwarding is not a prelude step: it lives inline in the
643    /// `.Call()` args — see `build_call_args_vec` — because a binding of the
644    /// missing sentinel errors on lookup.)
645    ///
646    /// `what` is the human-readable method label passed to `lifecycle_prelude`
647    /// (e.g., `"Type.method"` for S3/S4, `"Type$method"` for Env/R6/S7).
648    /// `indent` is the per-line prefix (e.g., `"  "` for 2-space, `"      "` for 6-space).
649    pub fn emit_method_prelude(
650        &self,
651        lines: &mut Vec<String>,
652        indent: &str,
653        what: &str,
654        formals: &str,
655    ) {
656        let m = self.method;
657        if let Some(ref entry) = m.method_attrs.r_entry {
658            for line in entry.lines() {
659                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent, line))
    })format!("{}{}", indent, line));
660            }
661        }
662        if let Some(ref on_exit) = m.method_attrs.r_on_exit {
663            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent,
                on_exit.to_r_code()))
    })format!("{}{}", indent, on_exit.to_r_code()));
664        }
665        if let Some(prelude) = m.lifecycle_prelude(what) {
666            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent, prelude))
    })format!("{}{}", indent, prelude));
667        }
668        if let Some(check) = m
669            .method_attrs
670            .dots_policy
671            .as_ref()
672            .and_then(|policy| policy.r_check(formals, self.call_attribution.r_check_call()))
673        {
674            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent, check))
    })format!("{}{}", indent, check));
675        }
676        for check in self.precondition_checks() {
677            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent, check))
    })format!("{}{}", indent, check));
678        }
679        for line in self.match_arg_prelude() {
680            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent, line))
    })format!("{}{}", indent, line));
681        }
682        if let Some(ref post) = m.method_attrs.r_post_checks {
683            for line in post.lines() {
684                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", indent, line))
    })format!("{}{}", indent, line));
685            }
686        }
687    }
688}
689
690// region: generated parameter docs
691
692/// The formals of `r_params` that `doc_tags` leaves undocumented, each with
693/// its choice text from `choice_docs` (the write-time placeholder of a
694/// `match_arg` parameter, the literal line of a `choices(...)` one), or
695/// `None` for a formal that has none. Skips `.mx_self`, `.ptr` and `...`, and
696/// yields nothing when the tags take the arguments from another topic
697/// (`roxygen::params_documented_elsewhere` against `page`, #1590). The class
698/// page default a builder appends is not in `doc_tags`, and an author
699/// `@rdname` naming `page` is the same page, so neither suppresses them.
700///
701/// Splits on top-level commas only: a naive `split(", ")` shreds a
702/// `mode = c("fast", "slow")` default into a bogus `"slow")` formal.
703fn generated_params<'p, 'd>(
704    r_params: &'p str,
705    doc_tags: &[String],
706    page: &str,
707    choice_docs: Option<&'d std::collections::HashMap<String, String>>,
708) -> Vec<(&'p str, Option<&'d str>)> {
709    if crate::roxygen::params_documented_elsewhere(doc_tags, Some(page)) {
710        return Vec::new();
711    }
712    crate::roxygen::split_r_formals(r_params)
713        .into_iter()
714        .map(crate::roxygen::formal_name)
715        .filter(|name| !#[allow(non_exhaustive_omitted_patterns)] match *name {
    ".ptr" | "..." | ".mx_self" => true,
    _ => false,
}matches!(*name, ".ptr" | "..." | ".mx_self"))
716        .filter(|name| !crate::roxygen::param_documented(doc_tags, name))
717        .map(|name| {
718            let choice = choice_docs
719                .and_then(|docs| docs.get(name))
720                .map(String::as_str);
721            (name, choice)
722        })
723        .collect()
724}
725
726/// Push one `#' @param name text` line per [`generated_params`] entry: the
727/// choice text for a choice parameter, `(undocumented)` otherwise (so
728/// `R CMD check` codoc sees every formal in `\usage` documented).
729fn push_param_filler(
730    lines: &mut Vec<String>,
731    r_params: &str,
732    doc_tags: &[String],
733    page: &str,
734    choice_docs: Option<&std::collections::HashMap<String, String>>,
735) {
736    for (name, choice) in generated_params(r_params, doc_tags, page, choice_docs) {
737        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @param {1} {0}",
                choice.unwrap_or("(undocumented)"), name))
    })format!(
738            "#' @param {name} {}",
739            choice.unwrap_or("(undocumented)")
740        ));
741    }
742}
743
744/// The documentation body of a method block that has no `\usage` (env
745/// methods): the author tags `forward` accepts, verbatim, then one
746/// `\describe{}` list with the author's `@param` items followed by the
747/// choice items of [`generated_params`]. A formal without choice text gets no
748/// item (nothing in `\usage` asks for one), and no `@param` line is emitted:
749/// with no usage entry to match, roxygen2 would write an `\arguments` entry
750/// that `R CMD check` reports as "Documented arguments not in \usage".
751///
752/// After a forwarded `@description` / `@details`, the list continues that
753/// section after a blank line; after `@examples` / `@examplesIf`, whose code
754/// block a blank line does not end, it opens a `@details`. Otherwise
755/// `@description Arguments of \code{<label>()}:` and a blank line open it:
756/// roxygen2 runs a tag's text up to the next tag, so after any other tag
757/// (`@return`, `@seealso`, `@title`, ...) the list would land in that tag's
758/// section, and an untagged list would be the block's intro paragraph, i.e.
759/// its title, which roxygen2 drops on the class page the block merges into.
760///
761/// `doc_tags` is the full tag list, so a page tag that `forward` rejects
762/// still sends the arguments elsewhere. Emits nothing when there is neither a
763/// forwarded tag nor an item.
764pub(crate) fn describe_params_lines(
765    doc_tags: &[String],
766    forward: impl Fn(&str) -> bool,
767    r_params: &str,
768    page: &str,
769    choice_docs: Option<&std::collections::HashMap<String, String>>,
770    label: &str,
771) -> Vec<String> {
772    let (param_tags, other_tags): (Vec<&str>, Vec<&str>) = doc_tags
773        .iter()
774        .map(String::as_str)
775        .filter(|tag| forward(tag))
776        .partition(|tag| tag.trim_start().starts_with("@param "));
777    let item = |name: &str, desc: &str| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  \\item{{\\code{{{0}}}}}{{{1}}}",
                name, desc))
    })format!("  \\item{{\\code{{{name}}}}}{{{desc}}}");
778    let mut items: Vec<String> = param_tags
779        .iter()
780        .filter_map(|tag| {
781            let rest = tag.trim_start().strip_prefix("@param ")?;
782            let (name, desc) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
783            Some(item(name, desc))
784        })
785        .collect();
786    items.extend(
787        generated_params(r_params, doc_tags, page, choice_docs)
788            .into_iter()
789            .filter_map(|(name, choice)| Some(item(name, choice?))),
790    );
791
792    let mut lines = Vec::new();
793    crate::roxygen::push_roxygen_tags_str(&mut lines, &other_tags);
794    if items.is_empty() {
795        return lines;
796    }
797    match other_tags
798        .last()
799        .and_then(|tag| crate::roxygen::roxygen_tag_name(tag))
800    {
801        Some("description" | "details") => lines.push("#'".to_string()),
802        Some("examples" | "examplesIf") => lines.push("#' @details".to_string()),
803        _ => {
804            // `%` starts an Rd comment and `\` an escape, also inside `\code{}`.
805            let label = label.replace('\\', "\\\\").replace('%', "\\%");
806            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @description Arguments of \\code{{{0}()}}:",
                label))
    })format!("#' @description Arguments of \\code{{{label}()}}:"));
807            lines.push("#'".to_string());
808        }
809    }
810    lines.push("#' \\describe{".to_string());
811    // One `#' ` per line, so a wrapped `@param` stays inside the block.
812    let item_refs: Vec<&str> = items.iter().map(String::as_str).collect();
813    crate::roxygen::push_roxygen_tags_str(&mut lines, &item_refs);
814    lines.push("#' }".to_string());
815    lines
816}
817
818// endregion
819
820/// Builder for class-level roxygen documentation header.
821///
822/// Generates the common roxygen tags that appear at the start of each class definition:
823/// - `@title` (unless user provided)
824/// - `@name` (unless user provided)
825/// - `@rdname` (unless user provided)
826/// - User-provided doc tags, as the [`CLASS_DOCS_MARKER`] line: the wrapper
827///   writer puts there the doc tags of every impl block of the type
828///   ([`ClassDocBuilder::docs`])
829/// - `@param` for the constructor formals the tags leave undocumented, when
830///   set (`with_ctor_params`; S3 and vctrs, whose class block is the
831///   constructor's block)
832/// - `@source Generated by miniextendr...`
833/// - Class-system-specific imports
834/// - `@export` (unless user provided, `@noRd`, or internal/noexport flags)
835///
836/// The generated `@title`, `@name`, `@rdname`, `@param` and `@export` lines
837/// are [`class_default`]s, which a tag on another impl block replaces.
838pub struct ClassDocBuilder<'a> {
839    /// The R-visible class name (e.g., `"Counter"`).
840    class_name: &'a str,
841    /// The Rust type identifier, used in the `@source` annotation.
842    type_ident: &'a syn::Ident,
843    /// User-provided roxygen tags extracted from doc comments.
844    doc_tags: &'a [String],
845    /// Human-readable label for the class system (e.g., `"R6"`, `"S3"`), used in
846    /// the auto-generated `@title`. Empty for Env classes, whose title is plain
847    /// `"<Class> Class"`.
848    class_system_label: &'static str,
849    /// Optional `@importFrom` tag for class-system-specific R packages
850    /// (e.g., `"@importFrom R6 R6Class"`).
851    imports: Option<String>,
852    /// When `true`, adds `@keywords internal` and suppresses `@export`.
853    /// Set by `#[miniextendr(internal)]`.
854    attr_internal: bool,
855    /// When `true`, suppresses `@export` but does not add `@keywords internal`.
856    /// Set by `#[miniextendr(noexport)]`.
857    attr_noexport: bool,
858    /// The constructor's R formals and its choice-parameter `@param` text
859    /// ([`choice_param_doc_map`]), for classes whose class block documents
860    /// the constructor (S3, vctrs). See [`ClassDocBuilder::with_ctor_params`].
861    ctor_params: Option<(&'a str, &'a std::collections::HashMap<String, String>)>,
862}
863
864impl<'a> ClassDocBuilder<'a> {
865    /// Create a new ClassDocBuilder with the given class metadata.
866    ///
867    /// By default, `@export` is included unless suppressed by user tags or
868    /// the `with_export_control` method.
869    pub fn new(
870        class_name: &'a str,
871        type_ident: &'a syn::Ident,
872        doc_tags: &'a [String],
873        class_system_label: &'static str,
874    ) -> Self {
875        Self {
876            class_name,
877            type_ident,
878            doc_tags,
879            class_system_label,
880            imports: None,
881            attr_internal: false,
882            attr_noexport: false,
883            ctor_params: None,
884        }
885    }
886
887    /// Document the constructor's formals on the class block: each formal the
888    /// doc tags leave undocumented gets `@param name <choice text>` for a
889    /// choice parameter and `@param name (undocumented)` otherwise, right after
890    /// the doc tags. Nothing under `@noRd` / plain `noexport`, or when the tags
891    /// take the arguments from another topic (`@rdname other`,
892    /// `@inheritParams`). For generators whose class block is the
893    /// constructor's block (S3 `new_<class>()`, vctrs `new_<class>()`).
894    pub fn with_ctor_params(
895        mut self,
896        params: &'a str,
897        choice_docs: &'a std::collections::HashMap<String, String>,
898    ) -> Self {
899        self.ctor_params = Some((params, choice_docs));
900        self
901    }
902
903    /// Set R package imports (e.g., "@importFrom R6 R6Class").
904    pub fn with_imports(mut self, imports: impl Into<String>) -> Self {
905        self.imports = Some(imports.into());
906        self
907    }
908
909    /// Set attribute-level internal/noexport flags from `ParsedImpl`.
910    pub fn with_export_control(mut self, internal: bool, noexport: bool) -> Self {
911        self.attr_internal = internal;
912        self.attr_noexport = noexport;
913        self
914    }
915
916    /// The user-provided doc tags as roxygen lines, for
917    /// [`ClassWrapper::class_docs`](crate::miniextendr_impl::ClassWrapper::class_docs).
918    pub fn docs(&self) -> Vec<String> {
919        let mut lines = Vec::new();
920        crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
921        lines
922    }
923
924    /// The class header as the writer writes a type with one impl block: the
925    /// [`docs`](Self::docs) in place of the marker, every default kept.
926    #[cfg(test)]
927    pub fn render(&self) -> Vec<String> {
928        self.build()
929            .into_iter()
930            .flat_map(|line| {
931                if line == CLASS_DOCS_MARKER {
932                    self.docs()
933                } else {
934                    vec![
935                        line.strip_prefix(crate::miniextendr_impl::CLASS_DEFAULT_PREFIX)
936                            .unwrap_or(&line)
937                            .to_string(),
938                    ]
939                }
940            })
941            .collect()
942    }
943
944    /// Build the roxygen `#' @tag` lines for the class header.
945    ///
946    /// Returns a vector of strings, each a complete roxygen comment line (e.g., `"#' @title ..."`),
947    /// a [`class_default`] or the [`CLASS_DOCS_MARKER`].
948    /// Auto-generates `@title`, `@name`, and `@rdname` if not provided by the user, and
949    /// respects `@noRd` to suppress all documentation output.
950    pub fn build(&self) -> Vec<String> {
951        let has_title = crate::roxygen::has_roxygen_tag(self.doc_tags, "title");
952        let has_name = crate::roxygen::has_roxygen_tag(self.doc_tags, "name");
953        let has_rdname = crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname");
954        let has_export = crate::roxygen::has_roxygen_tag(self.doc_tags, "export");
955        let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
956        let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
957        let effective_internal = has_internal || self.attr_internal;
958
959        // `noexport` (without `internal`) must produce no Rd contribution at all —
960        // no alias, no usage entry, nothing on a shared page — distinct from
961        // `internal`, which stays documented under `\keyword{internal}`. Fold a
962        // plain `noexport` into the same suppression gate as a user-written
963        // `@noRd`. `internal` wins if both flags are set on the same impl block
964        // (mirrors the standalone-fn `#[miniextendr(internal)]` precedence, where
965        // `internal` + `noexport` together is a compile error).
966        let suppress_rd = has_no_rd || (self.attr_noexport && !effective_internal);
967
968        let mut lines = Vec::new();
969
970        if suppress_rd && !has_no_rd {
971            lines.push("#' @noRd".to_string());
972        }
973
974        if !has_title && !suppress_rd {
975            // Env classes pass an empty label: "Counter Class", not "Counter  Class".
976            let title = if self.class_system_label.is_empty() {
977                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} Class", self.class_name))
    })format!("{} Class", self.class_name)
978            } else {
979                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} {1} Class", self.class_name,
                self.class_system_label))
    })format!("{} {} Class", self.class_name, self.class_system_label)
980            };
981            lines.push(class_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @title {0}", title))
    })format!("#' @title {title}")));
982        }
983        if !has_name && !suppress_rd {
984            lines.push(class_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @name {0}", self.class_name))
    })format!("#' @name {}", self.class_name)));
985        }
986        if !has_rdname && !suppress_rd {
987            lines.push(class_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @rdname {0}", self.class_name))
    })format!("#' @rdname {}", self.class_name)));
988        }
989        lines.push(CLASS_DOCS_MARKER.to_string());
990        if !suppress_rd {
991            if let Some((params, choice_docs)) = self.ctor_params {
992                let mut filler = Vec::new();
993                push_param_filler(
994                    &mut filler,
995                    params,
996                    self.doc_tags,
997                    self.class_name,
998                    Some(choice_docs),
999                );
1000                lines.extend(filler.into_iter().map(class_default));
1001            }
1002            // An impl-level `@rdname topic` puts the class block on an author
1003            // topic, whose own block names and titles the page (#1590).
1004            crate::roxygen::push_order_after_topic_blocks(
1005                &mut lines,
1006                self.doc_tags,
1007                self.class_name,
1008            );
1009            lines.extend(crate::roxygen::class_source_tag(self.type_ident));
1010        }
1011        if let Some(ref imports) = self.imports
1012            && !suppress_rd
1013        {
1014            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' {0}", imports))
    })format!("#' {}", imports));
1015        }
1016        // Inject @keywords internal if attr flag set and not already present
1017        if self.attr_internal && !has_internal && !suppress_rd {
1018            lines.push("#' @keywords internal".to_string());
1019        }
1020        // Don't auto-export if @noRd, @keywords internal, or attr flags are present
1021        if !has_export && !suppress_rd && !effective_internal && !self.attr_noexport {
1022            lines.push(class_default("#' @export"));
1023        }
1024
1025        lines
1026    }
1027}
1028
1029/// Builder for method-level roxygen documentation.
1030///
1031/// Generates roxygen tags for individual methods within a class. Methods share
1032/// the class's `@rdname` so they appear on the same help page. The builder handles
1033/// `@name` formatting (with optional prefix like `$` for `Class$method` style)
1034/// and respects `@noRd` inheritance from the parent class.
1035pub struct MethodDocBuilder<'a> {
1036    /// The R class name (e.g., `"Counter"`).
1037    class_name: &'a str,
1038    /// The Rust method name (e.g., `"inc"`).
1039    method_name: &'a str,
1040    /// The Rust type identifier, used in the `@source` annotation.
1041    type_ident: &'a syn::Ident,
1042    /// User-provided roxygen tags extracted from the method's doc comments.
1043    doc_tags: &'a [String],
1044    /// Optional separator between class name and method name in `@name`
1045    /// (e.g., `"$"` produces `@name Counter$inc`).
1046    name_prefix: Option<&'a str>,
1047    /// Override for the `@name` tag when the R function name differs from the Rust
1048    /// method name (e.g., for standalone S3 methods like `format.my_class`).
1049    r_name_override: Option<String>,
1050    /// When `true`, adds `@export` to the method (used for standalone S3/S4 generics).
1051    /// Defaults to `false` because `Class$method` access does not need separate export.
1052    always_export: bool,
1053    /// Whether the parent class has `@noRd`. When `true`, this method emits only
1054    /// `#' @noRd` and skips all other documentation tags.
1055    class_has_no_rd: bool,
1056    /// When `true`, convert `@param` tags into `\describe{}` blocks instead of
1057    /// roxygen `@param` entries, with the choice text of undocumented choice
1058    /// parameters as extra items ([`describe_params_lines`]).
1059    ///
1060    /// Used for env-class methods where roxygen cannot infer `\usage` from
1061    /// `Class$method <- function()`. Without this, `@param` tags create
1062    /// `\arguments` entries with no matching `\usage`, causing R CMD check
1063    /// warnings ("Documented arguments not in \\usage").
1064    params_as_details: bool,
1065    /// Optional comma-separated R parameter string for auto-generating `@param` tags.
1066    /// When set, any parameter not already documented gets `@param name (undocumented)`
1067    /// (in `params_as_details` mode only a choice parameter gets an item).
1068    r_params: Option<&'a str>,
1069    /// When `true`, filter out `@param` tags from the doc_tags before pushing.
1070    ///
1071    /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
1072    /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
1073    /// Including `@param` tags would create "Documented arguments not in \\usage" warnings.
1074    suppress_params: bool,
1075    /// Map of R-param-name → auto-generated `@param` text for choice
1076    /// parameters ([`choice_param_doc_map`]).
1077    ///
1078    /// When the auto-generated `@param` line would otherwise say `(undocumented)`,
1079    /// a `match_arg` param emits its placeholder instead, which the wrapper
1080    /// writer replaces with a rendered choice description (#210), and
1081    /// a `choices(...)` param its literal `One of ...` line.
1082    choice_param_docs: Option<&'a std::collections::HashMap<String, String>>,
1083    /// The Rust method named by the `@source Generated by miniextendr from
1084    /// `Type::method`` line ([`with_source_method`](Self::with_source_method)),
1085    /// else `method_name`.
1086    source_method: Option<&'a syn::Ident>,
1087}
1088
1089impl<'a> MethodDocBuilder<'a> {
1090    /// Create a new MethodDocBuilder with default settings.
1091    ///
1092    /// By default, `always_export` is `false` because methods accessed via `Class$method`
1093    /// should not be exported directly -- only the class env and standalone S3 methods
1094    /// need `@export`.
1095    pub fn new(
1096        class_name: &'a str,
1097        method_name: &'a str,
1098        type_ident: &'a syn::Ident,
1099        doc_tags: &'a [String],
1100    ) -> Self {
1101        Self {
1102            class_name,
1103            method_name,
1104            type_ident,
1105            doc_tags,
1106            name_prefix: None,
1107            r_name_override: None,
1108            always_export: false,
1109            class_has_no_rd: false,
1110            params_as_details: false,
1111            r_params: None,
1112            suppress_params: false,
1113            choice_param_docs: None,
1114            source_method: None,
1115        }
1116    }
1117
1118    /// Name `method` in the `@source` provenance line: the Rust method behind
1119    /// an R name that differs from it (a constructor's `new`, an `r_name`, a
1120    /// generic).
1121    pub fn with_source_method(mut self, method: &'a syn::Ident) -> Self {
1122        self.source_method = Some(method);
1123        self
1124    }
1125
1126    /// Supply the R-param-name → `@param` text map of the method's choice
1127    /// params ([`choice_param_doc_map`]). When the auto-generated `@param`
1128    /// line would otherwise say `(undocumented)`, that text is emitted instead
1129    /// (a `match_arg` placeholder is rewritten by the wrapper writer, #210).
1130    pub fn with_choice_param_docs(
1131        mut self,
1132        docs: &'a std::collections::HashMap<String, String>,
1133    ) -> Self {
1134        self.choice_param_docs = Some(docs);
1135        self
1136    }
1137
1138    /// Set a prefix for the @name tag (e.g., "$" for "Class$method").
1139    pub fn with_name_prefix(mut self, prefix: &'a str) -> Self {
1140        self.name_prefix = Some(prefix);
1141        self
1142    }
1143
1144    /// Override the @name tag with a custom R function name.
1145    ///
1146    /// Use this when the R function name differs from the Rust method name
1147    /// (e.g., for standalone S3/S4/S7 static methods like `s3counter_default_counter`).
1148    pub fn with_r_name(mut self, r_name: String) -> Self {
1149        self.r_name_override = Some(r_name);
1150        self
1151    }
1152
1153    /// Set whether the parent class has @noRd.
1154    ///
1155    /// When true, skips @name, @rdname, @source tags and adds @noRd instead.
1156    pub fn with_class_no_rd(mut self, class_has_no_rd: bool) -> Self {
1157        self.class_has_no_rd = class_has_no_rd;
1158        self
1159    }
1160
1161    /// Convert `@param` tags to inline `\describe{}` blocks instead of roxygen `@param`.
1162    ///
1163    /// Used for env-class methods where roxygen can't infer `\usage` from `Class$method <- function()`.
1164    /// Without this, `@param` tags create `\arguments` entries with no matching `\usage`,
1165    /// causing R CMD check warnings ("Documented arguments not in \\usage"). With
1166    /// [`with_r_params`](Self::with_r_params) and
1167    /// [`with_choice_param_docs`](Self::with_choice_param_docs), an undocumented
1168    /// choice parameter gets a list item with its choice text; no parameter
1169    /// gets an `(undocumented)` filler.
1170    pub fn with_params_as_details(mut self) -> Self {
1171        self.params_as_details = true;
1172        self
1173    }
1174
1175    /// Set the method's formal parameter names (comma-separated R params string).
1176    ///
1177    /// When set, auto-generates `@param name (undocumented)` for any parameter
1178    /// not already covered by a user `@param` tag. Skips `.mx_self`, `.ptr`, and
1179    /// `...` parameters. Generates nothing when the method's own tags take the
1180    /// arguments from another topic (`@rdname`, `@describeIn`,
1181    /// `@inheritParams`; see `roxygen::params_documented_elsewhere`).
1182    pub fn with_r_params(mut self, params: &'a str) -> Self {
1183        self.r_params = Some(params);
1184        self
1185    }
1186
1187    /// Suppress `@param` tags from user doc comments.
1188    ///
1189    /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
1190    /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
1191    pub fn with_suppress_params(mut self) -> Self {
1192        self.suppress_params = true;
1193        self
1194    }
1195
1196    /// Build the roxygen `#' @tag` lines for the method.
1197    ///
1198    /// Returns roxygen comment lines, with generated member defaults marked for
1199    /// the class assembler (`CLASS_MEMBER_DEFAULT_PREFIX`). If the parent
1200    /// class has `@noRd`, returns only `["#' @noRd"]`. Otherwise generates `@name`,
1201    /// `@rdname`, `@source`, and optionally `@export` tags, plus any user-provided tags.
1202    /// A method that joins an author topic (`@rdname other`, `@describeIn other`)
1203    /// gets no `@name` / `@rdname` next to `@describeIn`, and sorts after the
1204    /// topic's own block (`roxygen::ORDER_AFTER_TOPIC_BLOCKS`).
1205    pub fn build(&self) -> Vec<String> {
1206        let mut lines = Vec::new();
1207
1208        // If parent class has @noRd, skip all documentation and just add @noRd
1209        if self.class_has_no_rd {
1210            lines.push("#' @noRd".to_string());
1211            return lines;
1212        }
1213
1214        let r_name = if let Some(ref r_name) = self.r_name_override {
1215            r_name.clone()
1216        } else if let Some(prefix) = self.name_prefix {
1217            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}{2}", self.class_name,
                prefix, self.method_name))
    })format!("{}{}{}", self.class_name, prefix, self.method_name)
1218        } else {
1219            self.method_name.to_string()
1220        };
1221
1222        if self.params_as_details {
1223            // Env methods: no `\usage`, so the author's `@param` tags and the
1224            // choice text go into a `\describe{}` list (no `@param` filler).
1225            // A bare `@export` would export `Type$method` itself.
1226            lines.extend(describe_params_lines(
1227                self.doc_tags,
1228                crate::roxygen::forwarded_member_tag,
1229                self.r_params.unwrap_or(""),
1230                self.class_name,
1231                self.choice_param_docs,
1232                &r_name,
1233            ));
1234        } else {
1235            if self.suppress_params {
1236                // Filter out @param tags — they would create "Documented arguments
1237                // not in \usage" warnings for S4/S7 methods.
1238                let filtered: Vec<&str> = self
1239                    .doc_tags
1240                    .iter()
1241                    .filter(|t| {
1242                        !t.trim_start()
1243                            .strip_prefix('@')
1244                            .is_some_and(|rest| rest.starts_with("param"))
1245                    })
1246                    .map(|s| s.as_str())
1247                    .collect();
1248                crate::roxygen::push_roxygen_tags_str(&mut lines, &filtered);
1249            } else {
1250                crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
1251            }
1252
1253            // Auto-generate @param for undocumented method parameters, unless
1254            // the method's own tags send it to a topic that documents them
1255            // (`@rdname`, `@describeIn`) or inherit them (`@inheritParams`,
1256            // #1590). Choice params get their choice text (a match_arg
1257            // placeholder is rendered by the wrapper writer, #210).
1258            if let Some(params) = self.r_params {
1259                let mut fillers = Vec::new();
1260                push_param_filler(
1261                    &mut fillers,
1262                    params,
1263                    self.doc_tags,
1264                    self.class_name,
1265                    self.choice_param_docs,
1266                );
1267                lines.extend(
1268                    fillers
1269                        .into_iter()
1270                        .map(crate::miniextendr_impl::member_default),
1271                );
1272            }
1273        }
1274
1275        // A method-level `@describeIn topic ...` lists the method in `topic`'s
1276        // "Functions" section. roxygen2 rejects it next to `@name` or `@rdname`
1277        // ("can not be used with @name") and takes the page from the method's
1278        // object, so the builder pushes neither, and the destination topic
1279        // supplies the title. Only generators whose method block documents an
1280        // R object get here with the tag (`reject_unsupported_describe_in`).
1281        let describe_in = crate::roxygen::describe_in_topic(self.doc_tags).is_some();
1282        if !describe_in && !crate::roxygen::has_roxygen_tag(self.doc_tags, "name") {
1283            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @name {0}", r_name))
    })format!("#' @name {}", r_name));
1284        }
1285
1286        // A method-level `@rdname` splits the method onto its own page (#1438).
1287        // Method prose is demoted to `@description` (see `roxygen_tags_from_attrs`)
1288        // and the class page normally supplies the `@title`, so the new page
1289        // would have none and roxygen2 would skip it ("no name and/or title").
1290        // Follow the standalone-function convention (`lib.rs`) and use the
1291        // structural R name as the title unless the author wrote one. A
1292        // `@noRd` block renders no page, so it needs no title (#1552).
1293        let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
1294        if !describe_in {
1295            if crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname") {
1296                if !has_no_rd && !crate::roxygen::has_roxygen_tag(self.doc_tags, "title") {
1297                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @title {0}", r_name))
    })format!("#' @title {}", r_name));
1298                }
1299            } else {
1300                lines.push(crate::miniextendr_impl::member_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @rdname {0}", self.class_name))
    })format!(
1301                    "#' @rdname {}",
1302                    self.class_name
1303                )));
1304            }
1305        }
1306        // On an author topic the topic's own block names and titles the page
1307        // (`ORDER_AFTER_TOPIC_BLOCKS`); the structural title above only
1308        // matters for a page no other block defines.
1309        crate::roxygen::push_order_after_topic_blocks(&mut lines, self.doc_tags, self.class_name);
1310
1311        let method = match self.source_method {
1312            Some(ident) => ident.to_string(),
1313            None => self.method_name.to_string(),
1314        };
1315        lines.extend(crate::roxygen::source_tag(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("Generated by miniextendr from `{0}::{1}`",
                self.type_ident, method))
    })format!(
1316            "Generated by miniextendr from `{}::{}`",
1317            self.type_ident, method
1318        )));
1319
1320        let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
1321        // Don't auto-export if @noRd or @keywords internal is present
1322        if self.always_export
1323            && !crate::roxygen::has_roxygen_tag(self.doc_tags, "export")
1324            && !has_no_rd
1325            && !has_internal
1326        {
1327            lines.push("#' @export".to_string());
1328        }
1329
1330        lines
1331    }
1332}
1333
1334/// Extension trait for `ParsedImpl` to iterate over methods as [`MethodContext`].
1335///
1336/// Provides convenience methods that wrap `ParsedImpl`'s method iterators,
1337/// automatically constructing a `MethodContext` for each method. This avoids
1338/// repeating the `MethodContext::new(m, type_ident, label)` boilerplate in
1339/// every class system generator.
1340pub trait ParsedImplExt {
1341    /// Create a `MethodContext` for the constructor method, if one exists.
1342    fn constructor_context(&self) -> Option<MethodContext<'_>>;
1343
1344    /// Iterate over all instance methods (public + private + active) as `MethodContext`.
1345    fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
1346
1347    /// Iterate over static (non-receiver) methods as `MethodContext`.
1348    fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
1349
1350    /// Iterate over public instance methods as `MethodContext` (for R6 `public` list).
1351    fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
1352
1353    /// Iterate over private instance methods as `MethodContext` (for R6 `private` list).
1354    fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
1355
1356    /// Iterate over active binding methods as `MethodContext` (for R6 `active` list).
1357    fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
1358}
1359
1360impl ParsedImplExt for ParsedImpl {
1361    fn constructor_context(&self) -> Option<MethodContext<'_>> {
1362        let impl_prec = self.preconditions;
1363        self.constructor()
1364            .map(|m| MethodContext::new(m, &self.type_ident).with_impl_preconditions(impl_prec))
1365    }
1366
1367    fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
1368        let type_ident = &self.type_ident;
1369        let impl_prec = self.preconditions;
1370        self.instance_methods()
1371            .map(move |m| MethodContext::new(m, type_ident).with_impl_preconditions(impl_prec))
1372    }
1373
1374    fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
1375        let type_ident = &self.type_ident;
1376        let impl_prec = self.preconditions;
1377        self.static_methods()
1378            .map(move |m| MethodContext::new(m, type_ident).with_impl_preconditions(impl_prec))
1379    }
1380
1381    fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
1382        let type_ident = &self.type_ident;
1383        let impl_prec = self.preconditions;
1384        self.public_instance_methods()
1385            .map(move |m| MethodContext::new(m, type_ident).with_impl_preconditions(impl_prec))
1386    }
1387
1388    fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
1389        let type_ident = &self.type_ident;
1390        let impl_prec = self.preconditions;
1391        self.private_instance_methods()
1392            .map(move |m| MethodContext::new(m, type_ident).with_impl_preconditions(impl_prec))
1393    }
1394
1395    fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
1396        let type_ident = &self.type_ident;
1397        let impl_prec = self.preconditions;
1398        self.active_instance_methods()
1399            .map(move |m| MethodContext::new(m, type_ident).with_impl_preconditions(impl_prec))
1400    }
1401}
1402
1403/// Prefix an instance receiver and append only missing dispatch dots.
1404/// Shared by inherent and trait wrapper contexts; explicit Dots keeps its place.
1405pub(crate) fn instance_formals(params: &str, receiver: &str, include_dots: bool) -> String {
1406    let has_dots = crate::roxygen::split_r_formals(params)
1407        .iter()
1408        .any(|p| p.trim() == "...");
1409    let tail = if include_dots && !has_dots {
1410        ", ..."
1411    } else {
1412        ""
1413    };
1414    if params.is_empty() {
1415        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}{1}", receiver, tail))
    })format!("{receiver}{tail}")
1416    } else {
1417        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}, {1}{2}", receiver, params,
                tail))
    })format!("{receiver}, {params}{tail}")
1418    }
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423    #[test]
1424    fn env_parameter_details_end_examples_blocks() {
1425        let type_ident: syn::Ident = syn::parse_quote!(Example);
1426        for examples in [
1427            "@examples\nExample$new(1L)",
1428            "@examplesIf TRUE\nExample$new(1L)",
1429        ] {
1430            let tags = vec![
1431                "@param value Integer input.".to_owned(),
1432                examples.to_owned(),
1433            ];
1434            let docs = super::MethodDocBuilder::new("Example", "new", &type_ident, &tags)
1435                .with_params_as_details()
1436                .build()
1437                .join("\n");
1438            assert!(
1439                docs.contains("Example$new(1L)\n#' @details\n#' \\describe{"),
1440                "{docs}"
1441            );
1442        }
1443    }
1444
1445    /// An env method block drops the author's bare `@export`: roxygen2 would
1446    /// write `export("Example$new")`, which R refuses to load. The other tags,
1447    /// an `@export <symbol>` among them, are forwarded.
1448    #[test]
1449    fn env_method_drops_bare_export() {
1450        let type_ident: syn::Ident = syn::parse_quote!(Example);
1451        let tags: Vec<String> = [
1452            "@description Make one.",
1453            "@export",
1454            "@export example_helper",
1455            "@param value Integer input.",
1456        ]
1457        .map(str::to_owned)
1458        .to_vec();
1459        let docs = super::MethodDocBuilder::new("Example", "new", &type_ident, &tags)
1460            .with_name_prefix("$")
1461            .with_params_as_details()
1462            .build()
1463            .join("\n");
1464        assert!(
1465            !docs.lines().any(|l| l.trim_end() == "#' @export"),
1466            "{docs}"
1467        );
1468        for line in [
1469            "#' @description Make one.",
1470            "#' @export example_helper",
1471            "#' @name Example$new",
1472        ] {
1473            assert_eq!(
1474                docs.lines().filter(|l| *l == line).count(),
1475                1,
1476                "`{line}`: {docs}"
1477            );
1478        }
1479    }
1480
1481    /// A method whose own tags join or inherit a topic gets no generated
1482    /// `@param` lines (#1590); its own `@param` stays. The class-page default
1483    /// the builder appends is not an author tag, so a method on the class page
1484    /// keeps the filler for each undocumented argument, also when the author
1485    /// names that page with a redundant `@rdname Counter`.
1486    #[test]
1487    fn method_param_filler_only_on_the_class_page() {
1488        let type_ident: syn::Ident = syn::parse_quote!(Counter);
1489        let build = |tags: &[&str]| {
1490            let tags: Vec<String> = tags.iter().map(|t| t.to_string()).collect();
1491            super::MethodDocBuilder::new("Counter", "add", &type_ident, &tags)
1492                .with_r_params("by, times = 1L")
1493                .build()
1494                .join("\n")
1495        };
1496
1497        // No author page tag, or one naming the class page itself.
1498        for tags in [
1499            &["@param by Step size."][..],
1500            &["@param by Step size.", "@rdname Counter"][..],
1501        ] {
1502            let class_page = build(tags);
1503            assert!(class_page.contains("#' @rdname Counter"), "{class_page}");
1504            assert!(
1505                class_page.contains("#' @param times (undocumented)"),
1506                "{class_page}"
1507            );
1508        }
1509
1510        for tag in ["@rdname counter_ops", "@inheritParams counter_ops"] {
1511            let docs = build(&["@param by Step size.", tag]);
1512            assert_eq!(
1513                docs.matches("#' @param ").count(),
1514                1,
1515                "`{tag}`: only the author's @param, got:\n{docs}"
1516            );
1517            assert!(docs.contains("#' @param by Step size."), "{docs}");
1518        }
1519    }
1520
1521    /// A method-level `@describeIn` block carries neither `@name` nor
1522    /// `@rdname` (roxygen2: "@describeIn can not be used with @name") nor a
1523    /// structural title, sorts after the destination's own block, and leaves
1524    /// undocumented arguments to it. An `@rdname` naming another page sorts
1525    /// last too; the class page keeps the defaults.
1526    #[test]
1527    fn method_describe_in_drops_name_and_rdname() {
1528        let type_ident: syn::Ident = syn::parse_quote!(Counter);
1529        let order = format!("#' {}", crate::roxygen::ORDER_AFTER_TOPIC_BLOCKS);
1530        let build = |tags: &[&str]| {
1531            let tags: Vec<String> = tags.iter().map(|t| t.to_string()).collect();
1532            super::MethodDocBuilder::new("Counter", "add", &type_ident, &tags)
1533                .with_r_params("by, times = 1L")
1534                .with_r_name("add.Counter".to_string())
1535                .build()
1536        };
1537
1538        let described = build(&[
1539            "@describeIn counter_ops Add a step.",
1540            "@param by Step size.",
1541        ]);
1542        assert_eq!(
1543            described,
1544            [
1545                "#' @describeIn counter_ops Add a step.",
1546                "#' @param by Step size.",
1547                order.as_str(),
1548            ],
1549            "{described:#?}"
1550        );
1551
1552        let split = build(&["@rdname counter_ops"]);
1553        assert!(
1554            split.contains(&"#' @name add.Counter".to_string()),
1555            "{split:#?}"
1556        );
1557        assert!(
1558            split.contains(&"#' @title add.Counter".to_string()),
1559            "{split:#?}"
1560        );
1561        assert!(split.contains(&order), "{split:#?}");
1562
1563        let class_page = build(&[]);
1564        assert!(
1565            class_page.contains(&crate::miniextendr_impl::member_default(
1566                "#' @rdname Counter"
1567            )),
1568            "{class_page:#?}"
1569        );
1570        assert!(!class_page.contains(&order), "{class_page:#?}");
1571    }
1572
1573    use super::ClassDocBuilder;
1574
1575    #[test]
1576    fn test_method_context_static_call_no_args() {
1577        // This is a unit test for the static_call method
1578        // We'd need a mock ParsedMethod to test fully, but we can test the logic
1579        let call = ".Call(C_Test, .call = sys.call())";
1580        assert!(call.contains(".Call"));
1581    }
1582
1583    /// Audit A10: a class-level `#[miniextendr(noexport)]` (without `internal`)
1584    /// must produce no Rd contribution at all — no `@title`/`@name`/`@rdname`/
1585    /// `@export` — same as a user-written `@noRd`. Before the fix, `noexport`
1586    /// only suppressed `@export`, leaving the class fully documented (with an
1587    /// alias) minus the export line.
1588    #[test]
1589    fn test_class_noexport_suppresses_all_roxygen() {
1590        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
1591        let doc_tags: Vec<String> = vec![];
1592        let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, "R6")
1593            .with_export_control(false, true)
1594            .render();
1595        let joined = lines.join("\n");
1596
1597        assert!(
1598            lines.iter().any(|l| l == "#' @noRd"),
1599            "noexport should emit @noRd, got:\n{}",
1600            joined
1601        );
1602        assert!(
1603            !joined.contains("@title") && !joined.contains("@name") && !joined.contains("@rdname"),
1604            "noexport should suppress @title/@name/@rdname entirely, got:\n{}",
1605            joined
1606        );
1607        assert!(
1608            !joined.contains("@export"),
1609            "noexport should suppress @export, got:\n{}",
1610            joined
1611        );
1612    }
1613
1614    /// Companion: `#[miniextendr(internal)]` keeps the class documented (under
1615    /// `@keywords internal`) — it still contributes `@title`/`@name`/`@rdname`
1616    /// so it lands on a real help page, just unexported.
1617    #[test]
1618    fn test_class_internal_still_documented() {
1619        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
1620        let doc_tags: Vec<String> = vec![];
1621        let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, "R6")
1622            .with_export_control(true, false)
1623            .render();
1624        let joined = lines.join("\n");
1625
1626        assert!(
1627            !lines.iter().any(|l| l == "#' @noRd"),
1628            "internal should NOT emit @noRd (stays documented), got:\n{}",
1629            joined
1630        );
1631        assert!(
1632            joined.contains("@keywords internal"),
1633            "internal should add @keywords internal, got:\n{}",
1634            joined
1635        );
1636        assert!(
1637            joined.contains("@title") && joined.contains("@name") && joined.contains("@rdname"),
1638            "internal should still emit @title/@name/@rdname, got:\n{}",
1639            joined
1640        );
1641        assert!(
1642            !joined.contains("#' @export"),
1643            "internal should suppress @export, got:\n{}",
1644            joined
1645        );
1646    }
1647
1648    /// Neither flag set: normal fully-documented, exported class.
1649    #[test]
1650    fn test_class_no_flags_fully_documented_and_exported() {
1651        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
1652        let doc_tags: Vec<String> = vec![];
1653        let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, "R6")
1654            .with_export_control(false, false)
1655            .render();
1656        let joined = lines.join("\n");
1657
1658        assert!(!joined.contains("@noRd"));
1659        assert!(!joined.contains("@keywords internal"));
1660        assert!(
1661            joined.contains("@title") && joined.contains("@name") && joined.contains("@rdname")
1662        );
1663        assert!(joined.contains("#' @export"));
1664    }
1665
1666    /// The auto title names the class system, and has one space between words
1667    /// when there is no label (Env classes).
1668    #[test]
1669    fn test_class_title_spacing() {
1670        let type_ident: syn::Ident = syn::parse_str("Foo").unwrap();
1671        let doc_tags: Vec<String> = vec![];
1672        for (label, title) in [
1673            ("R6", "#' @title Foo R6 Class"),
1674            ("", "#' @title Foo Class"),
1675        ] {
1676            let lines = ClassDocBuilder::new("Foo", &type_ident, &doc_tags, label).render();
1677            assert_eq!(
1678                lines.iter().filter(|l| l.starts_with("#' @title")).count(),
1679                1
1680            );
1681            assert!(lines.iter().any(|l| l == title), "{lines:?}");
1682        }
1683    }
1684
1685    fn tags_of(tags: &[&str]) -> Vec<String> {
1686        tags.iter().map(|t| t.to_string()).collect()
1687    }
1688
1689    fn choice_docs(entries: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
1690        entries
1691            .iter()
1692            .map(|(k, v)| (k.to_string(), v.to_string()))
1693            .collect()
1694    }
1695
1696    /// An S3 / vctrs constructor gets one generated `@param` per formal its
1697    /// tags leave undocumented, before `@export`: the choice text for a
1698    /// choice parameter, `(undocumented)` otherwise. An author `@param` is
1699    /// kept and not repeated; `@noRd`, `noexport` and `@inheritParams` get
1700    /// none.
1701    #[test]
1702    fn class_ctor_params_fill_undocumented_formals() {
1703        let type_ident: syn::Ident = syn::parse_quote!(Point);
1704        let docs = choice_docs(&[("mode", "One of \"a\", \"b\".")]);
1705        let params = "x, y = 1, mode = c(\"a\", \"b\"), ...";
1706        let build = |tags: &[&str], internal: bool, noexport: bool| {
1707            let tags = tags_of(tags);
1708            ClassDocBuilder::new("Point", &type_ident, &tags, "S3")
1709                .with_ctor_params(params, &docs)
1710                .with_export_control(internal, noexport)
1711                .render()
1712        };
1713
1714        let lines = build(&["@param x The x coordinate."], false, false);
1715        assert_eq!(
1716            lines,
1717            [
1718                "#' @title Point S3 Class",
1719                "#' @name Point",
1720                "#' @rdname Point",
1721                "#' @param x The x coordinate.",
1722                "#' @param y (undocumented)",
1723                "#' @param mode One of \"a\", \"b\".",
1724                "#' @export",
1725            ],
1726            "{lines:#?}"
1727        );
1728
1729        let internal = build(&[], true, false);
1730        assert!(
1731            internal.contains(&"#' @param x (undocumented)".to_string()),
1732            "{internal:#?}"
1733        );
1734
1735        for (tags, noexport) in [
1736            (&["@noRd"][..], false),
1737            (&[][..], true),
1738            (&["@inheritParams point_ops"][..], false),
1739        ] {
1740            let lines = build(tags, false, noexport);
1741            assert!(
1742                !lines.iter().any(|l| l.starts_with("#' @param")),
1743                "{tags:?} noexport={noexport}: {lines:#?}"
1744            );
1745        }
1746    }
1747
1748    fn env_docs(
1749        tags: &[&str],
1750        params: &str,
1751        docs: &std::collections::HashMap<String, String>,
1752    ) -> Vec<String> {
1753        let type_ident: syn::Ident = syn::parse_quote!(Example);
1754        let tags = tags_of(tags);
1755        super::MethodDocBuilder::new("Example", "new", &type_ident, &tags)
1756            .with_name_prefix("$")
1757            .with_params_as_details()
1758            .with_r_params(params)
1759            .with_choice_param_docs(docs)
1760            .build()
1761    }
1762
1763    /// An env method (no `\usage`) lists the author's `@param` items and then
1764    /// the choice items in one `\describe{}`, with no `(undocumented)` item
1765    /// and no `@param` line (roxygen2 would report "Documented arguments not
1766    /// in \usage"). Unless a `@description` / `@details` tag directly
1767    /// precedes the list, a labelled `@description` lead-in and a blank line
1768    /// open it.
1769    #[test]
1770    fn env_params_share_one_describe_list() {
1771        let docs = choice_docs(&[("mode", "One of \"a\", \"b\".")]);
1772        let params = "n, plain, mode = c(\"a\", \"b\")";
1773
1774        for tags in [&[][..], &["@param n Amount."][..]] {
1775            let lines = env_docs(tags, params, &docs);
1776            assert!(!lines.iter().any(|l| l.contains("@param")), "{lines:#?}");
1777            assert!(
1778                !lines.iter().any(|l| l.contains("undocumented")),
1779                "{lines:#?}"
1780            );
1781            assert_eq!(lines.iter().filter(|l| *l == "#' \\describe{").count(), 1);
1782            let joined = lines.join("\n");
1783            assert!(
1784                joined.starts_with(
1785                    "#' @description Arguments of \\code{Example$new()}:\n#'\n#' \\describe{\n"
1786                ),
1787                "{joined}"
1788            );
1789            assert!(
1790                joined.contains("#'   \\item{\\code{mode}}{One of \"a\", \"b\".}\n#' }"),
1791                "{joined}"
1792            );
1793        }
1794        let with_author = env_docs(&["@param n Amount."], params, &docs).join("\n");
1795        assert!(
1796            with_author.contains(
1797                "#'   \\item{\\code{n}}{Amount.}\n#'   \\item{\\code{mode}}{One of \"a\", \"b\".}"
1798            ),
1799            "author item first: {with_author}"
1800        );
1801
1802        // A prose tag before the list keeps it in that section; after any
1803        // other tag the lead-in opens it, or the list would land in that
1804        // tag's section (`\value`, `\seealso`, the title).
1805        let lead_in = "#' @description Arguments of \\code{Example$new()}:\n#'\n#' \\describe{";
1806        for (last, sep) in [
1807            (
1808                "@description Plan it.",
1809                "#' @description Plan it.\n#'\n#' \\describe{".to_string(),
1810            ),
1811            (
1812                "@details More.",
1813                "#' @details More.\n#'\n#' \\describe{".to_string(),
1814            ),
1815            ("@seealso other", format!("#' @seealso other\n{lead_in}")),
1816            (
1817                "@return A value.",
1818                format!("#' @return A value.\n{lead_in}"),
1819            ),
1820            ("@title Mine", format!("#' @title Mine\n{lead_in}")),
1821        ] {
1822            let joined = env_docs(&[last], params, &docs).join("\n");
1823            assert!(joined.starts_with(&sep), "`{last}`: {joined}");
1824            assert_eq!(
1825                joined.matches("Arguments of").count(),
1826                usize::from(sep.contains("Arguments of")),
1827                "`{last}`: {joined}"
1828            );
1829        }
1830        // Prose and a trailing `@return`: the lead-in is a second
1831        // `@description`, which roxygen2 merges into the first.
1832        let joined = env_docs(
1833            &["@description Plan it.", "@return A value."],
1834            params,
1835            &docs,
1836        )
1837        .join("\n");
1838        assert!(
1839            joined.starts_with(&format!(
1840                "#' @description Plan it.\n#' @return A value.\n{lead_in}"
1841            )),
1842            "{joined}"
1843        );
1844
1845        // An author `@param` on the choice parameter replaces its choice item.
1846        let joined = env_docs(&["@param mode Mine."], params, &docs).join("\n");
1847        assert_eq!(
1848            joined.matches("\\item{\\code{mode}}").count(),
1849            1,
1850            "{joined}"
1851        );
1852        assert!(
1853            joined.contains("#'   \\item{\\code{mode}}{Mine.}"),
1854            "{joined}"
1855        );
1856        assert!(!joined.contains("One of"), "{joined}");
1857
1858        // No item and no tag: nothing at all; a plain formal gets no item.
1859        let empty = std::collections::HashMap::new();
1860        let nothing = env_docs(&[], "n, plain", &empty);
1861        assert!(
1862            nothing
1863                .iter()
1864                .all(|l| !l.contains("describe") && !l.contains("@description")),
1865            "{nothing:#?}"
1866        );
1867    }
1868
1869    /// Tags the caller does not forward still send the arguments elsewhere
1870    /// (`@inheritParams`), so no choice item is generated for them; the label
1871    /// is used verbatim, with `%` and `\` escaped for Rd.
1872    #[test]
1873    fn describe_params_lines_forward_and_label() {
1874        let docs = choice_docs(&[("mode", "One of \"a\", \"b\".")]);
1875        let tags = tags_of(&["@inheritParams family"]);
1876        let lines = super::describe_params_lines(
1877            &tags,
1878            |tag| !tag.starts_with("@inheritParams"),
1879            "mode = c(\"a\", \"b\")",
1880            "Example",
1881            Some(&docs),
1882            "Example$Trait$pick",
1883        );
1884        assert!(lines.is_empty(), "{lines:#?}");
1885
1886        let lines = super::describe_params_lines(
1887            &[],
1888            |_| true,
1889            "mode = c(\"a\", \"b\")",
1890            "Example",
1891            Some(&docs),
1892            "Example$%op%",
1893        );
1894        assert_eq!(
1895            lines[0], "#' @description Arguments of \\code{Example$\\%op\\%()}:",
1896            "{lines:#?}"
1897        );
1898    }
1899
1900    /// Every line of a wrapped author `@param` stays inside the roxygen
1901    /// block: the continuation line keeps its `#' ` lead instead of landing
1902    /// in wrappers.R as bare R code.
1903    #[test]
1904    fn env_wrapped_param_stays_in_roxygen_block() {
1905        let empty = std::collections::HashMap::new();
1906        let lines = env_docs(&["@param n Amount\nto add."], "n", &empty);
1907        assert!(
1908            lines.iter().all(|l| l
1909                .strip_prefix(crate::miniextendr_impl::CLASS_MEMBER_DEFAULT_PREFIX)
1910                .unwrap_or(l)
1911                .starts_with("#'")),
1912            "{lines:#?}"
1913        );
1914        let joined = lines.join("\n");
1915        assert!(
1916            joined.contains("#'   \\item{\\code{n}}{Amount\n#' to add.}"),
1917            "{joined}"
1918        );
1919    }
1920}