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