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.
92pub(crate) fn match_arg_doc_placeholder_map(
93 c_ident: &str,
94 method: &ParsedMethod,
95) -> std::collections::HashMap<String, String> {
96 let mut out = std::collections::HashMap::new();
97 for (rust_name, attrs) in &method.method_attrs.per_param {
98 if !attrs.match_arg {
99 continue;
100 }
101 let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
102 out.insert(
103 r_name.clone(),
104 match_arg_param_doc_placeholder(c_ident, &r_name),
105 );
106 }
107 out
108}
109
110/// Effective R-formal defaults for a method.
111///
112/// Layers defaults in priority order:
113/// 1. `#[miniextendr(match_arg)]` → ALWAYS a write-time placeholder that the
114/// cdylib resolves to `c("a", "b", ...)` at package-load time. Any user-
115/// supplied `default = "X"` is consumed elsewhere (rotates X to the front
116/// of the choice list at write time) rather than overriding the formal.
117/// 2. `#[miniextendr(choices("a", "b", ...))]` → `c("a", "b", ...)` formal default.
118/// 3. User-provided `#[miniextendr(defaults(param = "..."))]` for non-match_arg
119/// params.
120fn effective_r_defaults(
121 method: &ParsedMethod,
122 c_ident: &str,
123) -> std::collections::HashMap<String, String> {
124 let mut defaults = method.param_defaults.clone();
125 // match_arg → unconditionally splice the placeholder (overriding any user
126 // default, which is captured separately for write-time rotation).
127 for (rust_name, attrs) in &method.method_attrs.per_param {
128 if !attrs.match_arg {
129 continue;
130 }
131 let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
132 defaults.insert(r_name.clone(), match_arg_placeholder(c_ident, &r_name));
133 }
134 // choices(...) → c("a", "b", ...) formal. Lower priority than user
135 // defaults (kept for back-compat on non-match_arg params).
136 for (rust_name, attrs) in &method.method_attrs.per_param {
137 if let Some(choices) = attrs.choices.as_ref() {
138 let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
139 defaults.entry(r_name).or_insert_with(|| {
140 let quoted: Vec<String> = choices.iter().map(|c| format!("\"{c}\"")).collect();
141 format!("c({})", quoted.join(", "))
142 });
143 }
144 }
145 defaults
146}
147
148/// Pre-computed context for a method, holding all data needed for R wrapper generation.
149///
150/// This struct captures the common computations performed for every method across all
151/// class systems, reducing duplicate code. It pre-formats the C wrapper name, R formal
152/// parameters (with defaults), and R call arguments so each class generator can
153/// focus on its specific formatting logic.
154pub struct MethodContext<'a> {
155 /// Reference to the parsed method metadata.
156 pub method: &'a ParsedMethod,
157 /// The C wrapper identifier string (e.g., `"C_Counter__inc"`), used in `.Call()`.
158 pub c_ident: String,
159 /// R formals string with defaults (e.g., `"value, step = 1L"`), used in
160 /// `function(...)` signatures.
161 pub params: String,
162 /// R call arguments string without defaults (e.g., `"value, step"`), used
163 /// inside `.Call()` expressions.
164 pub args: String,
165}
166
167impl<'a> MethodContext<'a> {
168 /// Create a new MethodContext for a method.
169 ///
170 /// Computes the C wrapper identifier from the method name, type name, and optional
171 /// label (for multi-impl-block disambiguation), then formats the R formals and
172 /// call arguments from the method's signature and default values.
173 pub fn new(method: &'a ParsedMethod, type_ident: &syn::Ident, label: Option<&str>) -> Self {
174 let c_ident = method.c_wrapper_ident(type_ident, label).to_string();
175 let effective_defaults = effective_r_defaults(method, &c_ident);
176 let params =
177 crate::r_wrapper_builder::build_r_formals_from_sig(&method.sig, &effective_defaults);
178 let args = crate::r_wrapper_builder::build_r_call_args_from_sig(&method.sig);
179 Self {
180 method,
181 c_ident,
182 params,
183 args,
184 }
185 }
186
187 /// Build the R-param-name → @param placeholder map for this method's
188 /// match_arg params. Pass to `MethodDocBuilder::with_match_arg_doc_placeholders`
189 /// so the cdylib write pass rewrites the placeholders into rendered choice
190 /// descriptions (#210).
191 pub fn match_arg_doc_placeholders(&self) -> std::collections::HashMap<String, String> {
192 match_arg_doc_placeholder_map(&self.c_ident, self.method)
193 }
194
195 /// Build R prelude lines that validate `match_arg` / `choices` / `several_ok`
196 /// parameters via `base::match.arg()` before the `.Call()`.
197 ///
198 /// Returns an empty vector when the method declares none. Both `match_arg`
199 /// and `choices(...)` carry their choice list as the formal default
200 /// (`c("a", "b", ...)`), so `base::match.arg(arg)` finds the list by
201 /// itself — no second arg, no C helper lookup. `match_arg` adds a
202 /// factor → character coercion in front of `match.arg`.
203 ///
204 /// Callers should include these lines in the R wrapper body after parameter
205 /// defaulting but before the `.Call()`.
206 pub fn match_arg_prelude(&self) -> Vec<String> {
207 let mut lines = Vec::new();
208
209 for (rust_name, attrs) in &self.method.method_attrs.per_param {
210 if !attrs.match_arg {
211 continue;
212 }
213 let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
214 lines.push(format!(
215 "{r_name} <- if (is.factor({r_name})) as.character({r_name}) else {r_name}"
216 ));
217 if attrs.several_ok {
218 lines.push(format!(
219 "{r_name} <- base::match.arg({r_name}, several.ok = TRUE)"
220 ));
221 } else {
222 lines.push(format!("{r_name} <- base::match.arg({r_name})"));
223 }
224 }
225
226 for (rust_name, attrs) in &self.method.method_attrs.per_param {
227 if attrs.choices.is_none() {
228 continue;
229 }
230 let r_name = crate::r_wrapper_builder::normalize_r_arg_string(rust_name);
231 if attrs.several_ok {
232 lines.push(format!(
233 "{r_name} <- match.arg({r_name}, several.ok = TRUE)"
234 ));
235 } else {
236 lines.push(format!("{r_name} <- match.arg({r_name})"));
237 }
238 }
239
240 lines
241 }
242
243 /// Rust-side parameter names that are validated by R's `match.arg()` and therefore
244 /// don't need `stopifnot()` preconditions generated for them.
245 fn match_arg_skip_set(&self) -> std::collections::HashSet<String> {
246 let mut s = std::collections::HashSet::new();
247 for (rust_name, attrs) in &self.method.method_attrs.per_param {
248 if attrs.match_arg || attrs.choices.is_some() {
249 s.insert(crate::r_wrapper_builder::normalize_r_arg_string(rust_name));
250 }
251 }
252 s
253 }
254
255 /// Build the `.Call()` expression for a static/constructor call.
256 pub fn static_call(&self) -> String {
257 crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
258 .with_args_str(&self.args)
259 .build()
260 }
261
262 /// Build the `.Call()` expression for an instance method with `self` as ptr.
263 ///
264 /// The `self_expr` is typically "self", "private$.ptr", "x", "x@ptr", or "x@.ptr".
265 pub fn instance_call(&self, self_expr: &str) -> String {
266 crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
267 .with_self(self_expr)
268 .with_args_str(&self.args)
269 .build()
270 }
271
272 /// Like [`instance_call`](Self::instance_call) but passes `.call = NULL`.
273 ///
274 /// Use for lambda dispatch sites (S7 property getter/setter) where
275 /// `match.call()` captures the S7 dispatch frame, not the user's call.
276 pub fn instance_call_null_attr(&self, self_expr: &str) -> String {
277 crate::r_wrapper_builder::DotCallBuilder::new(&self.c_ident)
278 .null_call_attribution()
279 .with_self(self_expr)
280 .with_args_str(&self.args)
281 .build()
282 }
283
284 /// Build full R formals for instance methods (prefixing x/self parameter).
285 ///
286 /// For S3/S4/S7: `"x, <params>, ..."`
287 /// For Env/R6: `"<params>"` (self is implicit)
288 pub fn instance_formals(&self, add_self_param: bool) -> String {
289 self.instance_formals_with_dots(add_self_param, true)
290 }
291
292 /// Build full R formals for instance methods with optional dots.
293 ///
294 /// When `include_dots` is false, omits `...` from the signature.
295 /// This is used for strict generics that don't accept extra args.
296 pub fn instance_formals_with_dots(&self, add_self_param: bool, include_dots: bool) -> String {
297 if add_self_param {
298 if include_dots {
299 if self.params.is_empty() {
300 "x, ...".to_string()
301 } else {
302 format!("x, {}, ...", self.params)
303 }
304 } else {
305 // No dots - strict formals
306 if self.params.is_empty() {
307 "x".to_string()
308 } else {
309 format!("x, {}", self.params)
310 }
311 }
312 } else {
313 self.params.clone()
314 }
315 }
316
317 /// Get the generic name (uses override if present).
318 pub fn generic_name(&self) -> String {
319 self.method
320 .method_attrs
321 .generic
322 .clone()
323 .unwrap_or_else(|| self.method.ident.to_string())
324 }
325
326 /// Generate a source location comment for this method.
327 ///
328 /// Returns a string like `# Type::method (line:col)` using the method's span info.
329 /// The file name is already stated in the impl block header comment, so line:col
330 /// is sufficient to locate the method within that file.
331 pub fn source_comment(&self, type_ident: &syn::Ident) -> String {
332 let start = self.method.ident.span().start();
333 format!(
334 "# {}::{} ({}:{})",
335 type_ident,
336 self.method.ident,
337 start.line,
338 start.column + 1,
339 )
340 }
341
342 /// Check if this method uses a generic override (for existing generics like print).
343 pub fn has_generic_override(&self) -> bool {
344 self.method.method_attrs.generic.is_some()
345 }
346
347 /// Get custom class suffix if specified.
348 ///
349 /// This allows double-dispatch patterns like `vec_ptype2.my_class.my_class`
350 /// by specifying `#[miniextendr(s3(generic = "vec_ptype2", class = "my_class.my_class"))]`.
351 pub fn class_suffix(&self) -> Option<&str> {
352 self.method.method_attrs.class.as_deref()
353 }
354
355 /// Check if this method uses a custom class suffix.
356 pub fn has_class_override(&self) -> bool {
357 self.method.method_attrs.class.is_some()
358 }
359
360 /// Build R-side precondition `stopifnot()` lines for this method's parameters.
361 ///
362 /// Returns static checks for known types. Custom types not in the static table
363 /// are identified as fallback params but no R-side precheck is generated for them.
364 ///
365 /// Skips `self`/receiver parameters automatically (they are `FnArg::Receiver`) and
366 /// any parameter validated by `base::match.arg()` (via `match_arg` / `choices`) —
367 /// those already have a stronger runtime guarantee than `stopifnot(is.character(...))`.
368 pub fn precondition_checks(&self) -> Vec<String> {
369 crate::r_preconditions::build_precondition_checks(
370 &self.method.sig.inputs,
371 &self.match_arg_skip_set(),
372 )
373 .static_checks
374 }
375
376 /// Emit the 7-step method prelude into `lines`, each line prefixed with `indent`.
377 ///
378 /// The prelude is the standardised sequence that appears at the top of every
379 /// generated R method body, in order:
380 ///
381 /// 1. `r_entry` — user code injected before any checks
382 /// 2. `r_on_exit` — `on.exit(...)` cleanup
383 /// 3. `missing_prelude` — `if (missing(param)) param <- quote(expr=)` for `Missing<T>`
384 /// 4. `lifecycle_prelude` — deprecation/superseded banner (class-system-specific label)
385 /// 5. `precondition_checks` — `stopifnot(is.*(param))` for typed params
386 /// 6. `match_arg_prelude` — `base::match.arg(param)` validation
387 /// 7. `r_post_checks` — user code after all checks, before `.Call()`
388 ///
389 /// `what` is the human-readable method label passed to `lifecycle_prelude`
390 /// (e.g., `"Type.method"` for S3/S4, `"Type$method"` for Env/R6/S7).
391 /// `indent` is the per-line prefix (e.g., `" "` for 2-space, `" "` for 6-space).
392 pub fn emit_method_prelude(&self, lines: &mut Vec<String>, indent: &str, what: &str) {
393 let m = self.method;
394 if let Some(ref entry) = m.method_attrs.r_entry {
395 for line in entry.lines() {
396 lines.push(format!("{}{}", indent, line));
397 }
398 }
399 if let Some(ref on_exit) = m.method_attrs.r_on_exit {
400 lines.push(format!("{}{}", indent, on_exit.to_r_code()));
401 }
402 for line in self.missing_prelude() {
403 lines.push(format!("{}{}", indent, line));
404 }
405 if let Some(prelude) = m.lifecycle_prelude(what) {
406 lines.push(format!("{}{}", indent, prelude));
407 }
408 for check in self.precondition_checks() {
409 lines.push(format!("{}{}", indent, check));
410 }
411 for line in self.match_arg_prelude() {
412 lines.push(format!("{}{}", indent, line));
413 }
414 if let Some(ref post) = m.method_attrs.r_post_checks {
415 for line in post.lines() {
416 lines.push(format!("{}{}", indent, line));
417 }
418 }
419 }
420
421 /// Build `if (missing(param)) param <- quote(expr=)` prelude lines for Missing<T> parameters.
422 ///
423 /// Skips params that have a user-specified default (they get the default in formals instead).
424 pub fn missing_prelude(&self) -> Vec<String> {
425 crate::r_wrapper_builder::build_missing_prelude(
426 &self.method.sig.inputs,
427 &self.method.param_defaults,
428 )
429 }
430}
431
432/// Builder for class-level roxygen documentation header.
433///
434/// Generates the common roxygen tags that appear at the start of each class definition:
435/// - `@title` (unless user provided)
436/// - `@name` (unless user provided)
437/// - `@rdname` (unless user provided)
438/// - User-provided doc tags
439/// - `@source Generated by miniextendr...`
440/// - Class-system-specific imports
441/// - `@export` (unless user provided, `@noRd`, or internal/noexport flags)
442pub struct ClassDocBuilder<'a> {
443 /// The R-visible class name (e.g., `"Counter"`).
444 class_name: &'a str,
445 /// The Rust type identifier, used in the `@source` annotation.
446 type_ident: &'a syn::Ident,
447 /// User-provided roxygen tags extracted from doc comments.
448 doc_tags: &'a [String],
449 /// Human-readable label for the class system (e.g., `"R6"`, `"S3"`, `"Env"`),
450 /// used in the auto-generated `@title`.
451 class_system_label: &'static str,
452 /// Optional `@importFrom` tag for class-system-specific R packages
453 /// (e.g., `"@importFrom R6 R6Class"`).
454 imports: Option<String>,
455 /// When `true`, adds `@keywords internal` and suppresses `@export`.
456 /// Set by `#[miniextendr(internal)]`.
457 attr_internal: bool,
458 /// When `true`, suppresses `@export` but does not add `@keywords internal`.
459 /// Set by `#[miniextendr(noexport)]`.
460 attr_noexport: bool,
461}
462
463impl<'a> ClassDocBuilder<'a> {
464 /// Create a new ClassDocBuilder with the given class metadata.
465 ///
466 /// By default, `@export` is included unless suppressed by user tags or
467 /// the `with_export_control` method.
468 pub fn new(
469 class_name: &'a str,
470 type_ident: &'a syn::Ident,
471 doc_tags: &'a [String],
472 class_system_label: &'static str,
473 ) -> Self {
474 Self {
475 class_name,
476 type_ident,
477 doc_tags,
478 class_system_label,
479 imports: None,
480 attr_internal: false,
481 attr_noexport: false,
482 }
483 }
484
485 /// Set R package imports (e.g., "@importFrom R6 R6Class").
486 pub fn with_imports(mut self, imports: impl Into<String>) -> Self {
487 self.imports = Some(imports.into());
488 self
489 }
490
491 /// Set attribute-level internal/noexport flags from `ParsedImpl`.
492 pub fn with_export_control(mut self, internal: bool, noexport: bool) -> Self {
493 self.attr_internal = internal;
494 self.attr_noexport = noexport;
495 self
496 }
497
498 /// Build the roxygen `#' @tag` lines for the class header.
499 ///
500 /// Returns a vector of strings, each a complete roxygen comment line (e.g., `"#' @title ..."`).
501 /// Auto-generates `@title`, `@name`, and `@rdname` if not provided by the user, and
502 /// respects `@noRd` to suppress all documentation output.
503 pub fn build(&self) -> Vec<String> {
504 let has_title = crate::roxygen::has_roxygen_tag(self.doc_tags, "title");
505 let has_name = crate::roxygen::has_roxygen_tag(self.doc_tags, "name");
506 let has_rdname = crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname");
507 let has_export = crate::roxygen::has_roxygen_tag(self.doc_tags, "export");
508 let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
509 let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
510
511 let mut lines = Vec::new();
512
513 if !has_title && !has_no_rd {
514 lines.push(format!(
515 "#' @title {} {} Class",
516 self.class_name, self.class_system_label
517 ));
518 }
519 if !has_name && !has_no_rd {
520 lines.push(format!("#' @name {}", self.class_name));
521 }
522 if !has_rdname && !has_no_rd {
523 lines.push(format!("#' @rdname {}", self.class_name));
524 }
525 crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
526 if !has_no_rd {
527 lines.push(crate::roxygen::class_source_tag(self.type_ident));
528 }
529 if let Some(ref imports) = self.imports
530 && !has_no_rd
531 {
532 lines.push(format!("#' {}", imports));
533 }
534 // Inject @keywords internal if attr flag set and not already present
535 let effective_internal = has_internal || self.attr_internal;
536 if self.attr_internal && !has_internal && !has_no_rd {
537 lines.push("#' @keywords internal".to_string());
538 }
539 // Don't auto-export if @noRd, @keywords internal, or attr flags are present
540 if !has_export && !has_no_rd && !effective_internal && !self.attr_noexport {
541 lines.push("#' @export".to_string());
542 }
543
544 lines
545 }
546}
547
548/// Builder for method-level roxygen documentation.
549///
550/// Generates roxygen tags for individual methods within a class. Methods share
551/// the class's `@rdname` so they appear on the same help page. The builder handles
552/// `@name` formatting (with optional prefix like `$` for `Class$method` style)
553/// and respects `@noRd` inheritance from the parent class.
554pub struct MethodDocBuilder<'a> {
555 /// The R class name (e.g., `"Counter"`).
556 class_name: &'a str,
557 /// The Rust method name (e.g., `"inc"`).
558 method_name: &'a str,
559 /// The Rust type identifier, used in the `@source` annotation.
560 type_ident: &'a syn::Ident,
561 /// User-provided roxygen tags extracted from the method's doc comments.
562 doc_tags: &'a [String],
563 /// Optional separator between class name and method name in `@name`
564 /// (e.g., `"$"` produces `@name Counter$inc`).
565 name_prefix: Option<&'a str>,
566 /// Override for the `@name` tag when the R function name differs from the Rust
567 /// method name (e.g., for standalone S3 methods like `format.my_class`).
568 r_name_override: Option<String>,
569 /// When `true`, adds `@export` to the method (used for standalone S3/S4 generics).
570 /// Defaults to `false` because `Class$method` access does not need separate export.
571 always_export: bool,
572 /// Whether the parent class has `@noRd`. When `true`, this method emits only
573 /// `#' @noRd` and skips all other documentation tags.
574 class_has_no_rd: bool,
575 /// When `true`, convert `@param` tags into `\describe{}` blocks instead of
576 /// roxygen `@param` entries.
577 ///
578 /// Used for env-class methods where roxygen cannot infer `\usage` from
579 /// `Class$method <- function()`. Without this, `@param` tags create
580 /// `\arguments` entries with no matching `\usage`, causing R CMD check
581 /// warnings ("Documented arguments not in \\usage").
582 params_as_details: bool,
583 /// Optional comma-separated R parameter string for auto-generating `@param` tags.
584 /// When set, any parameter not already documented gets `@param name (undocumented)`.
585 r_params: Option<&'a str>,
586 /// When `true`, filter out `@param` tags from the doc_tags before pushing.
587 ///
588 /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
589 /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
590 /// Including `@param` tags would create "Documented arguments not in \\usage" warnings.
591 suppress_params: bool,
592 /// Map of R-param-name → write-time doc placeholder for match_arg parameters.
593 ///
594 /// When the auto-generated `@param` line would otherwise say `(undocumented)`,
595 /// a match_arg'd param emits the placeholder instead, which the cdylib's
596 /// write-time pass replaces with a rendered choice description (#210).
597 match_arg_doc_placeholders: Option<&'a std::collections::HashMap<String, String>>,
598}
599
600impl<'a> MethodDocBuilder<'a> {
601 /// Create a new MethodDocBuilder with default settings.
602 ///
603 /// By default, `always_export` is `false` because methods accessed via `Class$method`
604 /// should not be exported directly -- only the class env and standalone S3 methods
605 /// need `@export`.
606 pub fn new(
607 class_name: &'a str,
608 method_name: &'a str,
609 type_ident: &'a syn::Ident,
610 doc_tags: &'a [String],
611 ) -> Self {
612 Self {
613 class_name,
614 method_name,
615 type_ident,
616 doc_tags,
617 name_prefix: None,
618 r_name_override: None,
619 always_export: false,
620 class_has_no_rd: false,
621 params_as_details: false,
622 r_params: None,
623 suppress_params: false,
624 match_arg_doc_placeholders: None,
625 }
626 }
627
628 /// Supply a map from R-param-name to a write-time doc placeholder for
629 /// match_arg'd params. When the auto-generated `@param` line would otherwise
630 /// say `(undocumented)`, the placeholder is emitted instead and the cdylib
631 /// write pass rewrites it to a rendered choice description. See #210.
632 pub fn with_match_arg_doc_placeholders(
633 mut self,
634 placeholders: &'a std::collections::HashMap<String, String>,
635 ) -> Self {
636 self.match_arg_doc_placeholders = Some(placeholders);
637 self
638 }
639
640 /// Set a prefix for the @name tag (e.g., "$" for "Class$method").
641 pub fn with_name_prefix(mut self, prefix: &'a str) -> Self {
642 self.name_prefix = Some(prefix);
643 self
644 }
645
646 /// Override the @name tag with a custom R function name.
647 ///
648 /// Use this when the R function name differs from the Rust method name
649 /// (e.g., for standalone S3/S4/S7 static methods like `s3counter_default_counter`).
650 pub fn with_r_name(mut self, r_name: String) -> Self {
651 self.r_name_override = Some(r_name);
652 self
653 }
654
655 /// Set whether the parent class has @noRd.
656 ///
657 /// When true, skips @name, @rdname, @source tags and adds @noRd instead.
658 pub fn with_class_no_rd(mut self, class_has_no_rd: bool) -> Self {
659 self.class_has_no_rd = class_has_no_rd;
660 self
661 }
662
663 /// Convert `@param` tags to inline `\describe{}` blocks instead of roxygen `@param`.
664 ///
665 /// Used for env-class methods where roxygen can't infer `\usage` from `Class$method <- function()`.
666 /// Without this, `@param` tags create `\arguments` entries with no matching `\usage`,
667 /// causing R CMD check warnings ("Documented arguments not in \\usage").
668 pub fn with_params_as_details(mut self) -> Self {
669 self.params_as_details = true;
670 self
671 }
672
673 /// Set the method's formal parameter names (comma-separated R params string).
674 ///
675 /// When set, auto-generates `@param name (undocumented)` for any parameter
676 /// not already covered by a user `@param` tag. Skips `self`, `.ptr`, and
677 /// `...` parameters.
678 pub fn with_r_params(mut self, params: &'a str) -> Self {
679 self.r_params = Some(params);
680 self
681 }
682
683 /// Suppress `@param` tags from user doc comments.
684 ///
685 /// Used for S4/S7 instance methods where the method is defined via `setMethod()`
686 /// or `S7::method()` assignment, which roxygen2 doesn't parse for `\usage` entries.
687 pub fn with_suppress_params(mut self) -> Self {
688 self.suppress_params = true;
689 self
690 }
691
692 /// Build the roxygen `#' @tag` lines for the method.
693 ///
694 /// Returns a vector of strings, each a complete roxygen comment line. If the parent
695 /// class has `@noRd`, returns only `["#' @noRd"]`. Otherwise generates `@name`,
696 /// `@rdname`, `@source`, and optionally `@export` tags, plus any user-provided tags.
697 pub fn build(&self) -> Vec<String> {
698 let mut lines = Vec::new();
699
700 // If parent class has @noRd, skip all documentation and just add @noRd
701 if self.class_has_no_rd {
702 lines.push("#' @noRd".to_string());
703 return lines;
704 }
705
706 if !self.doc_tags.is_empty() {
707 if self.params_as_details {
708 // For env-class: emit non-@param tags normally, convert @param to \describe
709 let (param_tags, other_tags): (Vec<_>, Vec<_>) = self
710 .doc_tags
711 .iter()
712 .partition(|t| t.trim_start().starts_with("@param "));
713 let other_refs: Vec<&str> = other_tags.iter().map(|s| s.as_str()).collect();
714 crate::roxygen::push_roxygen_tags_str(&mut lines, &other_refs);
715 if !param_tags.is_empty() {
716 // Only add blank separator if the previous line isn't @title
717 // (roxygen2 treats blank lines after @title as multi-paragraph titles)
718 let last_is_title = lines.last().is_some_and(|l| l.contains("@title"));
719 if !last_is_title {
720 lines.push("#'".to_string());
721 }
722 lines.push("#' \\describe{".to_string());
723 for tag in ¶m_tags {
724 if let Some(rest) = tag.trim_start().strip_prefix("@param ") {
725 let mut parts = rest.splitn(2, char::is_whitespace);
726 let name = parts.next().unwrap_or("");
727 let desc = parts.next().unwrap_or("");
728 lines.push(format!("#' \\item{{\\code{{{name}}}}}{{{desc}}}"));
729 }
730 }
731 lines.push("#' }".to_string());
732 }
733 } else if self.suppress_params {
734 // Filter out @param tags — they would create "Documented arguments
735 // not in \usage" warnings for S4/S7 methods.
736 let filtered: Vec<&str> = self
737 .doc_tags
738 .iter()
739 .filter(|t| {
740 !t.trim_start()
741 .strip_prefix('@')
742 .is_some_and(|rest| rest.starts_with("param"))
743 })
744 .map(|s| s.as_str())
745 .collect();
746 crate::roxygen::push_roxygen_tags_str(&mut lines, &filtered);
747 } else {
748 crate::roxygen::push_roxygen_tags(&mut lines, self.doc_tags);
749 }
750 }
751
752 // Auto-generate @param for undocumented method parameters
753 if let Some(params) = self.r_params {
754 for param in params.split(", ").filter(|p| !p.is_empty()) {
755 let param_name = param.split('=').next().unwrap_or(param).trim();
756 if param_name == ".ptr" || param_name == "..." || param_name == "self" {
757 continue;
758 }
759 let already_documented = self
760 .doc_tags
761 .iter()
762 .any(|t| t.starts_with(&format!("@param {}", param_name)));
763 if !already_documented {
764 // match_arg'd params get a placeholder the cdylib write-pass
765 // replaces with the rendered choice description (#210).
766 let body = self
767 .match_arg_doc_placeholders
768 .and_then(|m| m.get(param_name))
769 .map(|s| s.as_str())
770 .unwrap_or("(undocumented)");
771 lines.push(format!("#' @param {} {}", param_name, body));
772 }
773 }
774 }
775
776 if !crate::roxygen::has_roxygen_tag(self.doc_tags, "name") {
777 let name = if let Some(ref r_name) = self.r_name_override {
778 r_name.clone()
779 } else if let Some(prefix) = self.name_prefix {
780 format!("{}{}{}", self.class_name, prefix, self.method_name)
781 } else {
782 self.method_name.to_string()
783 };
784 lines.push(format!("#' @name {}", name));
785 }
786
787 if !crate::roxygen::has_roxygen_tag(self.doc_tags, "rdname") {
788 lines.push(format!("#' @rdname {}", self.class_name));
789 }
790
791 lines.push(format!(
792 "#' @source Generated by miniextendr from `{}::{}`",
793 self.type_ident, self.method_name
794 ));
795
796 let has_no_rd = crate::roxygen::has_roxygen_tag(self.doc_tags, "noRd");
797 let has_internal = crate::roxygen::has_roxygen_tag(self.doc_tags, "keywords internal");
798 // Don't auto-export if @noRd or @keywords internal is present
799 if self.always_export
800 && !crate::roxygen::has_roxygen_tag(self.doc_tags, "export")
801 && !has_no_rd
802 && !has_internal
803 {
804 lines.push("#' @export".to_string());
805 }
806
807 lines
808 }
809}
810
811/// Extension trait for `ParsedImpl` to iterate over methods as [`MethodContext`].
812///
813/// Provides convenience methods that wrap `ParsedImpl`'s method iterators,
814/// automatically constructing a `MethodContext` for each method. This avoids
815/// repeating the `MethodContext::new(m, type_ident, label)` boilerplate in
816/// every class system generator.
817pub trait ParsedImplExt {
818 /// Create a `MethodContext` for the constructor method, if one exists.
819 fn constructor_context(&self) -> Option<MethodContext<'_>>;
820
821 /// Iterate over all instance methods (public + private + active) as `MethodContext`.
822 fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
823
824 /// Iterate over static (non-receiver) methods as `MethodContext`.
825 fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
826
827 /// Iterate over public instance methods as `MethodContext` (for R6 `public` list).
828 fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
829
830 /// Iterate over private instance methods as `MethodContext` (for R6 `private` list).
831 fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
832
833 /// Iterate over active binding methods as `MethodContext` (for R6 `active` list).
834 fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>>;
835}
836
837impl ParsedImplExt for ParsedImpl {
838 fn constructor_context(&self) -> Option<MethodContext<'_>> {
839 self.constructor()
840 .map(|m| MethodContext::new(m, &self.type_ident, self.label()))
841 }
842
843 fn instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
844 let type_ident = &self.type_ident;
845 let label = self.label();
846 self.instance_methods()
847 .map(move |m| MethodContext::new(m, type_ident, label))
848 }
849
850 fn static_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
851 let type_ident = &self.type_ident;
852 let label = self.label();
853 self.static_methods()
854 .map(move |m| MethodContext::new(m, type_ident, label))
855 }
856
857 fn public_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
858 let type_ident = &self.type_ident;
859 let label = self.label();
860 self.public_instance_methods()
861 .map(move |m| MethodContext::new(m, type_ident, label))
862 }
863
864 fn private_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
865 let type_ident = &self.type_ident;
866 let label = self.label();
867 self.private_instance_methods()
868 .map(move |m| MethodContext::new(m, type_ident, label))
869 }
870
871 fn active_instance_method_contexts(&self) -> impl Iterator<Item = MethodContext<'_>> {
872 let type_ident = &self.type_ident;
873 let label = self.label();
874 self.active_instance_methods()
875 .map(move |m| MethodContext::new(m, type_ident, label))
876 }
877}
878
879#[cfg(test)]
880mod tests {
881 #[test]
882 fn test_method_context_static_call_no_args() {
883 // This is a unit test for the static_call method
884 // We'd need a mock ParsedMethod to test fully, but we can test the logic
885 let call = ".Call(C_Test, .call = match.call())";
886 assert!(call.contains(".Call"));
887 }
888}