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 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 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![,]>,
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| format!("\"{c}\"")).collect();
239 let choices_expr = 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![,]>,
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| format!("\"{c}\"")).collect();
358 if let Some(default) = attrs.choice_formal(&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 = 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 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(format!("{}{}", indent, line));
660 }
661 }
662 if let Some(ref on_exit) = m.method_attrs.r_on_exit {
663 lines.push(format!("{}{}", indent, on_exit.to_r_code()));
664 }
665 if let Some(prelude) = m.lifecycle_prelude(what) {
666 lines.push(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(format!("{}{}", indent, check));
675 }
676 for check in self.precondition_checks() {
677 lines.push(format!("{}{}", indent, check));
678 }
679 for line in self.match_arg_prelude() {
680 lines.push(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(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| !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(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| 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(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 format!("{} Class", self.class_name)
978 } else {
979 format!("{} {} Class", self.class_name, self.class_system_label)
980 };
981 lines.push(class_default(format!("#' @title {title}")));
982 }
983 if !has_name && !suppress_rd {
984 lines.push(class_default(format!("#' @name {}", self.class_name)));
985 }
986 if !has_rdname && !suppress_rd {
987 lines.push(class_default(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(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 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(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(format!("#' @title {}", r_name));
1298 }
1299 } else {
1300 lines.push(crate::miniextendr_impl::member_default(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(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 format!("{receiver}{tail}")
1416 } else {
1417 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}