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