miniextendr_macros/miniextendr_impl/s7_class.rs
1//! S7-class R wrapper generator.
2//!
3//! Generates `S7::new_class(...)` with **value semantics, formal property
4//! validation, and parent-based inheritance**. Property attributes
5//! (`s7(getter)` / `s7(setter, prop = "...")`) compile to `S7::new_property`
6//! with `@`-access semantics, including read-only computed properties and
7//! read-write dynamic ones. Similar formal power to S4 with cleaner syntax,
8//! but the S7 ecosystem is younger and the R-package dependency surface is
9//! evolving. Pick S7 for **new** packages wanting modern formal OOP; use S4
10//! when you need Bioconductor compatibility or multi-dispatch.
11
12use super::{ParsedImpl, ParsedMethod};
13use crate::r_class_formatter::{class_ref_or_verbatim, is_bare_identifier};
14
15/// Extract the full property documentation from a getter's `doc_tags` for use in
16/// `@prop <name> <doc>` emission.
17///
18/// A getter's leading prose is promoted to `@description` (see
19/// `roxygen::leading_prose_from_attrs`); a getter's `@title` would only ever be a
20/// hand-written explicit tag. This helper collects any `@title`, `@description`, and
21/// `@details` present and joins them with `\n\n` for multi-line `@prop` continuation
22/// (supported by roxygen2 8.0.0+).
23fn extract_prop_doc_from_tags(doc_tags: &[String]) -> Option<String> {
24 let mut parts: Vec<String> = Vec::new();
25
26 // Look for @title (primary doc — from para 1)
27 if let Some(title) = doc_tags.iter().find_map(|t| {
28 if !t.starts_with('@') {
29 return Some(t.clone()); // plain text before any @tag
30 }
31 t.strip_prefix("@title ").map(|s| s.to_string())
32 }) {
33 parts.push(title);
34 }
35
36 // Look for @description (para 2)
37 if let Some(desc) = doc_tags
38 .iter()
39 .find_map(|t| t.strip_prefix("@description ").map(|s| s.to_string()))
40 {
41 parts.push(desc);
42 }
43
44 // Look for @details (para 3+)
45 if let Some(details) = doc_tags
46 .iter()
47 .find_map(|t| t.strip_prefix("@details ").map(|s| s.to_string()))
48 {
49 parts.push(details);
50 }
51
52 if parts.is_empty() {
53 None
54 } else {
55 Some(parts.join("\n\n"))
56 }
57}
58
59/// S7-property variant of [`class_ref_or_verbatim`] that asks the resolver to
60/// fall back silently to `S7::class_any` on miss (unregistered type, or a
61/// registered-but-non-S7 class). Prevents the load-time `object not found`
62/// noise called out in #203.
63fn class_ref_or_any_or_verbatim(name: &str) -> String {
64 if is_bare_identifier(name) {
65 format!(".__MX_CLASS_REF_OR_ANY_{name}__")
66 } else {
67 name.to_string()
68 }
69}
70
71/// Roxygen prose for the fast-path dispatch shortcut advisory block.
72///
73/// Shared between the inherent-impl S7 generator ([`generate_s7_r_wrapper`])
74/// and the trait-impl S7 generator
75/// (`miniextendr_impl_trait::r_wrappers::generate_trait_s7_r_wrapper`) so both
76/// emit identical guidance. Returns `#'`-prefixed lines (no trailing `@param`
77/// / `@name` scaffolding — the caller appends those).
78pub(crate) fn shortcut_advisory_lines(method_name: &str, class_name: &str) -> Vec<String> {
79 vec![
80 format!(
81 "#' Fast-path shortcut for the `{}` S7 method on `{}`.",
82 method_name, class_name
83 ),
84 "#'".to_string(),
85 "#' Calls the underlying Rust routine directly, bypassing `S7::S7_dispatch()`".to_string(),
86 "#' (the class walk + method-table lookup). Use in hot loops where the".to_string(),
87 "#' per-call dispatch overhead matters. **Footgun:** this shortcut does not".to_string(),
88 "#' perform subclass dispatch \u{2014} a method override defined on a child class"
89 .to_string(),
90 "#' will *not* be honoured. Use the generic when subclassing is possible.".to_string(),
91 ]
92}
93
94/// Map a Rust return type to an S7 class name.
95///
96/// Returns `None` if the type doesn't map to any S7 class — the caller
97/// then omits the `class = …` constraint (so S7 uses `class_any`).
98///
99/// # S7 Class Mapping
100///
101/// | Rust Type | S7 Class |
102/// |-----------|----------|
103/// | `i32`, `i16`, `i8` | `class_integer` |
104/// | `f64`, `f32` | `class_double` |
105/// | `bool` | `class_logical` |
106/// | `u8` | `class_raw` |
107/// | `String`, `&str` | `class_character` |
108/// | `Vec<i32>` | `class_integer` |
109/// | `Vec<f64>` | `class_double` |
110/// | `Vec<bool>` | `class_logical` |
111/// | `Vec<String>` | `class_character` |
112/// | `Option<T>` | `NULL | class_T` (union) |
113/// | `SomeUserType` (bare ident) | `.__MX_CLASS_REF_SomeUserType__` |
114///
115/// For bare user-defined path types the macro emits the same
116/// `.__MX_CLASS_REF_<Type>__` placeholder used for parent-class and
117/// `convert_from`/`convert_to` references (see [`#154`]). The resolver in
118/// `miniextendr_api::registry::write_r_wrappers_to_file` swaps the
119/// placeholder for the R-visible class name recorded in `MX_CLASS_NAMES`
120/// — so `class = "Override"` on an S7 impl block is honored, and child
121/// class properties tighten from `class_any` to the real class.
122///
123/// Unresolved types fall through the existing CLASS_REF mechanism:
124/// the bare Rust name is emitted with a compile-time warning. If the
125/// user returns a type that isn't a registered class at all, they'll
126/// see that warning + a `object '…' not found` at R load time.
127pub(super) fn rust_type_to_s7_class(ty: &syn::Type) -> Option<String> {
128 match ty {
129 syn::Type::Path(type_path) => {
130 let seg = type_path.path.segments.last()?;
131 let ident = seg.ident.to_string();
132
133 match ident.as_str() {
134 // Scalar types
135 "i32" | "i16" | "i8" | "isize" => Some("S7::class_integer".to_string()),
136 "f64" | "f32" => Some("S7::class_double".to_string()),
137 "bool" => Some("S7::class_logical".to_string()),
138 "u8" => Some("S7::class_raw".to_string()),
139 "String" => Some("S7::class_character".to_string()),
140
141 // Vec types - check inner type
142 "Vec" => {
143 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
144 && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
145 {
146 // Recursively get the inner type's class
147 return rust_type_to_s7_class(inner);
148 }
149 None
150 }
151
152 // Option types - create union with NULL
153 "Option" => {
154 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
155 && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
156 && let Some(inner_class) = rust_type_to_s7_class(inner)
157 {
158 return Some(format!("NULL | {}", inner_class));
159 }
160 None
161 }
162
163 // Result types - use the Ok type
164 "Result" => {
165 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
166 && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
167 {
168 return rust_type_to_s7_class(inner);
169 }
170 None
171 }
172
173 // Bare, un-generic single-segment identifier — reuse the
174 // CLASS_REF write-time placeholder so the resolver swaps
175 // in the registered R-visible class name (honoring any
176 // `class = "Override"` on the referenced impl block).
177 // Paths with `::`, generics, or a lowercase leading char
178 // are rejected to avoid matching crate-local aliases,
179 // primitives, or type parameters — those fall through
180 // to `None` and the caller omits the `class =` entirely.
181 _ if type_path.path.segments.len() == 1
182 && matches!(seg.arguments, syn::PathArguments::None)
183 && is_bare_identifier(&ident)
184 && ident.chars().next().is_some_and(|c| c.is_ascii_uppercase()) =>
185 {
186 // S7 property class constraint: use the OR_ANY variant so
187 // an unregistered type (or a registered-but-non-S7 class)
188 // falls back silently to `class_any`, restoring pre-#154
189 // behavior for those edge cases (#203).
190 Some(class_ref_or_any_or_verbatim(&ident))
191 }
192
193 _ => None,
194 }
195 }
196 syn::Type::Reference(type_ref) => {
197 // Handle &str
198 if let syn::Type::Path(type_path) = type_ref.elem.as_ref()
199 && let Some(seg) = type_path.path.segments.last()
200 && seg.ident == "str"
201 {
202 return Some("S7::class_character".to_string());
203 }
204 // Recurse for other reference types
205 rust_type_to_s7_class(&type_ref.elem)
206 }
207 _ => None,
208 }
209}
210
211/// Generates the complete R wrapper string for an S7-style class.
212///
213/// Produces the following R code:
214/// - Class definition: `ClassName <- S7::new_class("ClassName", ...)` with a `.ptr` property
215/// of `class_any` holding the `ExternalPtr`, plus optional computed properties
216/// - Constructor: inline in `new_class(constructor = function(...) ...)`, supports
217/// `.ptr` shortcut parameter for factory methods returning `Self`
218/// - Properties: `S7::new_property(...)` for each getter/setter/validator annotated
219/// with `#[miniextendr(s7(getter))]` etc., with support for class constraints,
220/// defaults, required, frozen, and deprecated modifiers
221/// - Instance methods: `S7::new_generic(...)` + `S7::method(generic, class)` pairs
222/// dispatching to Rust `.Call()` wrappers via `x@.ptr`
223/// - External generics: `S7::new_external_generic("pkg", "name")` for overriding
224/// generics from other packages
225/// - Multiple dispatch: via `#[miniextendr(s7(dispatch = "x,y"))]`
226/// - Fallback methods: `S7::method(generic, S7::class_any)` with `tryCatch` for
227/// safe slot access on non-S7 objects
228/// - Static methods: regular functions named `ClassName_method(...)`
229/// - Convert methods: `S7::method(convert, list(From, To))` for `convert_from`
230/// and `convert_to` annotations
231/// - S7 parent/abstract: optional `parent` and `abstract = TRUE` in class definition
232///
233/// Roxygen2 documentation and `@importFrom S7 ...` tags are generated automatically.
234pub fn generate_s7_r_wrapper(parsed_impl: &ParsedImpl) -> String {
235 use crate::r_class_formatter::{
236 ClassDocBuilder, MethodContext, MethodDocBuilder, ParsedImplExt, should_export_from_tags,
237 };
238
239 let class_name = parsed_impl.class_name();
240 let type_ident = &parsed_impl.type_ident;
241 let class_doc_tags = &parsed_impl.doc_tags;
242 // Check if class has @noRd - if so, skip method documentation and exports. A
243 // plain `noexport` (without `internal`) is folded in too — it must suppress
244 // Rd contribution entirely, matching `ClassDocBuilder::build`'s `suppress_rd`
245 // gate. `should_export` (below) already independently gates @export/@exportMethod.
246 let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
247 || (parsed_impl.noexport && !parsed_impl.internal);
248 let should_export =
249 should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
250
251 let mut lines = Vec::new();
252
253 // Collect S7 property getters, setters, and validators
254 // Property name is: s7_prop if specified, else method name
255 // We store method idents so we can look them up later
256 /// Accumulated metadata for a single S7 property, built up from getter,
257 /// setter, and validator method annotations during the first pass over methods.
258 struct S7Property {
259 /// Property name (from `#[miniextendr(s7(prop = "..."))]` or the method ident).
260 name: String,
261 /// Ident of the method annotated with `#[miniextendr(s7(getter))]`.
262 getter_method_ident: Option<String>,
263 /// Ident of the method annotated with `#[miniextendr(s7(setter))]`.
264 setter_method_ident: Option<String>,
265 /// Ident of the method annotated with `#[miniextendr(s7(validate))]`.
266 validator_method_ident: Option<String>,
267 /// S7 class type inferred from the getter's return type (e.g., `"S7::class_double"`).
268 class_type: Option<String>,
269 /// Default value as an R expression string (from `#[miniextendr(s7(default = "..."))]`).
270 default_value: Option<String>,
271 /// When `true`, the property errors if not provided during construction.
272 required: bool,
273 /// When `true`, the property can only be set once (subsequent sets error).
274 frozen: bool,
275 /// If set, a deprecation warning is emitted when the property is accessed or set.
276 deprecated: Option<String>,
277 /// Documentation extracted from the getter method's first doc line.
278 /// Used to emit `#' @prop name doc` in the class-level roxygen block.
279 doc: Option<String>,
280 }
281
282 let mut properties: std::collections::BTreeMap<String, S7Property> =
283 std::collections::BTreeMap::new();
284 let mut property_method_idents: std::collections::HashSet<String> =
285 std::collections::HashSet::new();
286
287 // First pass: collect all property methods (getters, setters, validators)
288 for method in &parsed_impl.methods {
289 if !method.should_include() {
290 continue;
291 }
292 let attrs = &method.method_attrs;
293
294 if attrs.s7.getter || attrs.s7.setter || attrs.s7.validate {
295 let method_ident = method.ident.to_string();
296 let prop_name = attrs
297 .s7
298 .prop
299 .clone()
300 .unwrap_or_else(|| method_ident.clone());
301
302 property_method_idents.insert(method_ident.clone());
303
304 let entry = properties.entry(prop_name.clone()).or_insert(S7Property {
305 name: prop_name,
306 getter_method_ident: None,
307 setter_method_ident: None,
308 validator_method_ident: None,
309 class_type: None,
310 default_value: None,
311 required: false,
312 frozen: false,
313 deprecated: None,
314 doc: None,
315 });
316
317 if attrs.s7.getter {
318 entry.getter_method_ident = Some(method_ident.clone());
319 // Extract S7 class type from getter's return type
320 if let syn::ReturnType::Type(_, ret_type) = &method.sig.output {
321 entry.class_type = rust_type_to_s7_class(ret_type);
322 }
323 // Capture property attributes from getter
324 if let Some(ref default) = attrs.s7.default {
325 entry.default_value = Some(default.clone());
326 }
327 if attrs.s7.required {
328 entry.required = true;
329 }
330 if attrs.s7.frozen {
331 entry.frozen = true;
332 }
333 if let Some(ref msg) = attrs.s7.deprecated {
334 entry.deprecated = Some(msg.clone());
335 }
336 // Extract full doc from getter's doc comment for @prop documentation
337 // in the class-level roxygen block. After #578 the auto_description
338 // path emits @title (para 1), @description (para 2), @details (para 3+).
339 // We collect all three to surface the complete doc under @prop.
340 // Multi-line @prop is supported by roxygen2 8.0.0 (continuation lines
341 // indented with two spaces).
342 entry.doc = extract_prop_doc_from_tags(&method.doc_tags);
343 }
344 if attrs.s7.setter {
345 entry.setter_method_ident = Some(method_ident.clone());
346 }
347 if attrs.s7.validate {
348 entry.validator_method_ident = Some(method_ident);
349 }
350 }
351 }
352
353 // Helper to find method by ident
354 let find_method = |ident: &str| -> Option<&ParsedMethod> {
355 parsed_impl.methods.iter().find(|m| m.ident == ident)
356 };
357
358 // Constructor - check if .ptr param will be added (for static methods returning Self)
359 let has_self_returning_methods = parsed_impl
360 .methods
361 .iter()
362 .filter(|m| m.should_include())
363 .any(|m| m.returns_self());
364
365 // Determine imports based on whether we have properties and what class types are used
366 let base_imports = "new_class class_any new_object S7_object new_generic method";
367 let mut import_parts: Vec<&str> = vec![base_imports];
368
369 if !properties.is_empty() {
370 import_parts.push("new_property");
371 }
372
373 // Check if any methods use S7 convert (convert_from or convert_to)
374 let has_convert_methods = parsed_impl.methods.iter().any(|m| {
375 m.should_include()
376 && (m.method_attrs.s7.convert_from.is_some() || m.method_attrs.s7.convert_to.is_some())
377 });
378 if has_convert_methods {
379 import_parts.push("convert");
380 }
381
382 // Collect unique S7 class types used in properties
383 let mut class_imports: std::collections::HashSet<&str> = std::collections::HashSet::new();
384 for prop in properties.values() {
385 if let Some(ref class_type) = prop.class_type {
386 // Extract class name from "S7::class_xxx" or "NULL | S7::class_xxx"
387 for part in class_type.split('|') {
388 let part = part.trim();
389 if let Some(class_name) = part.strip_prefix("S7::") {
390 class_imports.insert(class_name);
391 }
392 }
393 }
394 }
395 // Sort for deterministic output
396 let mut sorted_imports: Vec<&str> = class_imports.into_iter().collect();
397 sorted_imports.sort();
398 for class_name in sorted_imports {
399 import_parts.push(class_name);
400 }
401
402 let imports = format!("@importFrom S7 {}", import_parts.join(" "));
403
404 // Class definition with documentation
405 lines.extend(
406 ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S7")
407 .with_imports(&imports)
408 .with_export_control(parsed_impl.internal, parsed_impl.noexport)
409 .build(),
410 );
411 // Inject lifecycle imports from methods into class-level roxygen block
412 if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
413 parsed_impl
414 .methods
415 .iter()
416 .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
417 ) {
418 let insert_pos = lines.len().saturating_sub(1);
419 lines.insert(insert_pos, format!("#' {}", lc_import));
420 }
421
422 // Document constructor params — include constructor @param tags and auto-generate
423 // for undocumented ones. Class-level @param tags are already emitted by ClassDocBuilder;
424 // constructor-level @param tags must be explicitly pushed here since S7 inlines the
425 // constructor inside new_class().
426 // Skip if class has @noRd
427 if !class_has_no_rd {
428 if let Some(ctx) = parsed_impl.constructor_context() {
429 let mx_doc = ctx.match_arg_doc_placeholders();
430 for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
431 let param_name = param.split('=').next().unwrap_or(param).trim();
432 if param_name == ".ptr" || param_name == "..." {
433 continue;
434 }
435 // Check if already documented at the class level (impl block doc_tags)
436 let in_class_docs = class_doc_tags
437 .iter()
438 .any(|t| t.starts_with(&format!("@param {}", param_name)));
439 if in_class_docs {
440 continue; // Already emitted by ClassDocBuilder
441 }
442 // Check if documented in the constructor method's doc_tags
443 let ctor_tag = ctx
444 .method
445 .doc_tags
446 .iter()
447 .find(|t| t.starts_with(&format!("@param {}", param_name)));
448 if let Some(tag) = ctor_tag {
449 lines.push(format!("#' {}", tag));
450 } else if let Some(placeholder) = mx_doc.get(param_name) {
451 // match_arg'd constructor param — placeholder rewritten at
452 // cdylib write time to rendered choice description (#210).
453 lines.push(format!("#' @param {} {}", param_name, placeholder));
454 } else {
455 lines.push(format!("#' @param {} (undocumented)", param_name));
456 }
457 }
458 }
459 // .ptr is always a constructor param
460 if !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr") {
461 lines.push(
462 "#' @param .ptr Internal pointer (used by static methods, not for direct use)."
463 .to_string(),
464 );
465 }
466
467 // @prop tags for impl-block S7 properties (getter/setter pairs).
468 // These appear in the class-level @rdname page via roxygen2's @prop tag.
469 //
470 // Properties that ARE constructor formals are already documented above via
471 // @param and must NOT also receive @prop — roxygen2 8.0.0 treats @param /
472 // @prop as disjoint sets (constructor formals → @param; getter/setter-only
473 // props → @prop). Collect the constructor-formal names and skip them here.
474 let constructor_param_names: std::collections::HashSet<String> =
475 if let Some(ctx) = parsed_impl.constructor_context() {
476 ctx.params
477 .split(", ")
478 .filter(|p| !p.is_empty())
479 .map(|p| p.split('=').next().unwrap_or(p).trim().to_string())
480 .filter(|n| n != ".ptr" && n != "...")
481 .collect()
482 } else {
483 std::collections::HashSet::new()
484 };
485 for prop in properties.values() {
486 if constructor_param_names.contains(&prop.name) {
487 continue; // already documented as @param above
488 }
489 let doc = prop.doc.as_deref().unwrap_or("(undocumented property)");
490 // Emit @prop with multi-line continuation support (roxygen2 8.0.0+).
491 // First line: `#' @prop <name> <para1>`.
492 // Additional paragraphs: `#' <continuation>` (two-space indent).
493 let mut prop_lines = doc.lines();
494 if let Some(first_line) = prop_lines.next() {
495 lines.push(format!("#' @prop {} {}", prop.name, first_line));
496 for continuation in prop_lines {
497 lines.push(format!("#' {}", continuation));
498 }
499 } else {
500 lines.push(format!("#' @prop {} {}", prop.name, doc));
501 }
502 }
503
504 // @prop tags for sidecar (r_data_accessors) properties.
505 // Emitted via a write-time placeholder that MX_S7_SIDECAR_PROPS resolves.
506 // The placeholder is replaced in `write_r_wrappers_to_file` with one
507 // `#' @prop field doc` line per registered sidecar field.
508 if parsed_impl.r_data_accessors {
509 let type_name = type_ident.to_string();
510 lines.push(format!(".__MX_S7_SIDECAR_PROP_DOCS_{type_name}__"));
511 }
512 }
513
514 // S7::new_class — optionally include parent and abstract
515 if let Some(ref parent) = parsed_impl.s7_parent {
516 // Use a placeholder so the resolver can look up the actual R class name
517 // at cdylib write time (handles `class = "Override"` on the parent).
518 let parent_ref = class_ref_or_verbatim(parent);
519 lines.push(format!(
520 "{} <- S7::new_class(\"{}\", parent = {},",
521 class_name, class_name, parent_ref
522 ));
523 } else {
524 lines.push(format!(
525 "{} <- S7::new_class(\"{}\",",
526 class_name, class_name
527 ));
528 }
529
530 if parsed_impl.s7_abstract {
531 lines.push(" abstract = TRUE,".to_string());
532 }
533
534 // Properties - .ptr holds the ExternalPtr, plus computed/dynamic properties
535 // When r_data_accessors is set, merge with sidecar properties from #[derive(ExternalPtr)]
536 // Collect property items into a vec, then join with ",\n" to avoid bare commas on standalone lines
537 let mut prop_items: Vec<String> = Vec::new();
538 prop_items.push(" .ptr = S7::class_any".to_string());
539
540 // Generate computed/dynamic properties
541 for prop in properties.values() {
542 // Generate property definition
543 let mut prop_parts = Vec::new();
544
545 // Add class constraint if known (inferred from getter return type)
546 if let Some(ref class_type) = prop.class_type {
547 prop_parts.push(format!("class = {}", class_type));
548 }
549
550 // Handle default value or required pattern
551 if prop.required {
552 // Required pattern: error if not provided
553 prop_parts.push(format!(
554 "default = quote(stop(\"@{} is required\"))",
555 prop.name
556 ));
557 } else if let Some(ref default) = prop.default_value {
558 // Explicit default value (R expression)
559 prop_parts.push(format!("default = {}", default));
560 }
561
562 // Add validator if present
563 if let Some(ref validator_ident) = prop.validator_method_ident
564 && let Some(validator_method) = find_method(validator_ident)
565 {
566 let ctx = MethodContext::new(validator_method, type_ident, parsed_impl.label());
567 // Validator is called with just the value, not self.
568 // Use null_call_attribution: this runs inside S7's dispatch lambda, so
569 // match.call() would capture S7 internals, not the user's call site.
570 let validator_call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
571 .null_call_attribution()
572 .with_args(&["value"])
573 .build();
574 prop_parts.push(format!("validator = function(value) {validator_call}"));
575 }
576
577 // Generate getter (with optional deprecation warning)
578 if let Some(ref getter_ident) = prop.getter_method_ident
579 && let Some(getter_method) = find_method(getter_ident)
580 {
581 let ctx = MethodContext::new(getter_method, type_ident, parsed_impl.label());
582 // Use null_call_attribution: runs inside S7's property dispatch lambda.
583 let getter_call = ctx.instance_call_null_attr("self@.ptr");
584 if let Some(ref msg) = prop.deprecated {
585 // Deprecated getter: emit warning then return value
586 prop_parts.push(format!(
587 "getter = function(self) {{ warning(\"Property @{} is deprecated: {}\"); {} }}",
588 prop.name, msg, getter_call
589 ));
590 } else {
591 prop_parts.push(format!("getter = function(self) {}", getter_call));
592 }
593 }
594
595 // Generate setter (with optional frozen/deprecation handling)
596 if let Some(ref setter_ident) = prop.setter_method_ident
597 && let Some(setter_method) = find_method(setter_ident)
598 {
599 let ctx = MethodContext::new(setter_method, type_ident, parsed_impl.label());
600 // Use null_call_attribution: runs inside S7's property dispatch lambda.
601 let setter_call = ctx.instance_call_null_attr("self@.ptr");
602
603 if prop.frozen {
604 // Frozen pattern: error if property was already set (non-NULL)
605 // Note: This is a simplified check; true frozen behavior would need
606 // a separate flag in the object to track if ever set
607 if let Some(ref msg) = prop.deprecated {
608 prop_parts.push(format!(
609 "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
610 prop.name, msg, prop.name, prop.name, setter_call
611 ));
612 } else {
613 prop_parts.push(format!(
614 "setter = function(self, value) {{ if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
615 prop.name, prop.name, setter_call
616 ));
617 }
618 } else if let Some(ref msg) = prop.deprecated {
619 // Deprecated setter: emit warning then set value
620 prop_parts.push(format!(
621 "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); {}; self }}",
622 prop.name, msg, setter_call
623 ));
624 } else {
625 // Normal setter
626 prop_parts.push(format!(
627 "setter = function(self, value) {{ {}; self }}",
628 setter_call
629 ));
630 }
631 }
632
633 if prop_parts.is_empty() {
634 // This shouldn't happen, but handle gracefully
635 prop_items.push(format!(" {} = S7::new_property()", prop.name));
636 } else {
637 prop_items.push(format!(
638 " {} = S7::new_property({})",
639 prop.name,
640 prop_parts.join(", ")
641 ));
642 }
643 }
644
645 if parsed_impl.r_data_accessors {
646 lines.push(" properties = c(list(".to_string());
647 } else {
648 lines.push(" properties = list(".to_string());
649 }
650 lines.push(prop_items.join(",\n"));
651
652 // Close the properties list (or merge with sidecar properties)
653 if parsed_impl.r_data_accessors {
654 let type_name = type_ident.to_string();
655 lines.push(format!(" ), .rdata_properties_{}),", type_name));
656 } else {
657 lines.push(" ),".to_string());
658 }
659
660 if let Some(ctx) = parsed_impl.constructor_context() {
661 let ctor_preconditions = ctx.precondition_checks();
662 let ctor_match_arg = ctx.match_arg_prelude();
663 if has_self_returning_methods {
664 let params_with_ptr = if ctx.params.is_empty() {
665 ".ptr = NULL".to_string()
666 } else {
667 format!("{}, .ptr = NULL", ctx.params)
668 };
669 lines.push(format!(" constructor = function({}) {{", params_with_ptr));
670 // Preconditions + match.arg only when not using .ptr shortcut
671 if !ctor_preconditions.is_empty() || !ctor_match_arg.is_empty() {
672 lines.push(" if (is.null(.ptr)) {".to_string());
673 for check in &ctor_preconditions {
674 lines.push(format!(" {}", check));
675 }
676 for line in &ctor_match_arg {
677 lines.push(format!(" {}", line));
678 }
679 lines.push(" }".to_string());
680 }
681 lines.push(" if (!is.null(.ptr)) {".to_string());
682 lines.push(" S7::new_object(S7::S7_object(), .ptr = .ptr)".to_string());
683 lines.push(" } else {".to_string());
684 lines.push(format!(" .val <- {}", ctx.static_call()));
685 lines.extend(crate::method_return_builder::condition_check_lines(
686 " ",
687 ));
688 lines.push(" S7::new_object(S7::S7_object(), .ptr = .val)".to_string());
689 lines.push(" }".to_string());
690 lines.push(" }".to_string());
691 } else {
692 lines.push(format!(" constructor = function({}) {{", ctx.params));
693 for check in &ctor_preconditions {
694 lines.push(format!(" {}", check));
695 }
696 for line in &ctor_match_arg {
697 lines.push(format!(" {}", line));
698 }
699 lines.push(format!(" .val <- {}", ctx.static_call()));
700 lines.extend(crate::method_return_builder::condition_check_lines(" "));
701 lines.push(" S7::new_object(S7::S7_object(), .ptr = .val)".to_string());
702 lines.push(" }".to_string());
703 }
704 }
705
706 lines.push(")".to_string());
707 lines.push(String::new());
708
709 // Instance methods as S7 generics + methods
710 // Skip methods that are property getters/setters (they're handled as S7 properties)
711 for ctx in parsed_impl.instance_method_contexts() {
712 let method_ident = ctx.method.ident.to_string();
713 if property_method_idents.contains(&method_ident) {
714 continue;
715 }
716
717 let generic_name = ctx.generic_name();
718 let full_params = ctx.instance_formals(true); // adds x, ..., params
719 let method_attrs = &ctx.method.method_attrs;
720
721 // Emit the generic-doc marker BEFORE the source comment so that the
722 // write-time pass can place the standalone Rd page before the method block.
723 // This ensures the synthesised doc block (ending with NULL) is always
724 // separated from the method's roxygen comment by the source `# comment` line
725 // — preventing roxygen2 from merging the two blocks into one.
726 //
727 // Only package-owned (non-external, non-override) generics get a marker;
728 // fallback methods dispatch on S7::class_any and don't define a new generic.
729 if !ctx.has_generic_override() && !method_attrs.s7.fallback && !class_has_no_rd {
730 let dispatch_str = method_attrs
731 .s7
732 .dispatch
733 .as_deref()
734 .unwrap_or("x")
735 .replace(' ', "");
736 let no_dots_str = if method_attrs.s7.no_dots {
737 "true"
738 } else {
739 "false"
740 };
741 lines.push(format!(
742 ".__MX_GENERIC_DOC__(kind=\"S7\", generic=\"{generic_name}\", class=\"{class_name}\", export={should_export}, dispatch=\"{dispatch_str}\", no_dots={no_dots_str})"
743 ));
744 }
745
746 lines.push(ctx.source_comment(type_ident));
747
748 // For fallback methods (class_any), check class before using @ to extract
749 // the pointer. Non-S7 objects can't have @.ptr — error in R rather than
750 // passing a wrong type to Rust (which would segfault).
751 let self_expr = if method_attrs.s7.fallback {
752 "if (inherits(x, \"S7_object\")) x@.ptr else stop(paste0(\"expected an S7 object, got \", class(x)[[1]]))"
753 } else {
754 "x@.ptr"
755 };
756 let call = ctx.instance_call(self_expr);
757
758 // Determine dispatch class (fallback -> class_any, normal -> class_name)
759 let method_class = if method_attrs.s7.fallback {
760 "S7::class_any".to_string()
761 } else {
762 class_name.clone()
763 };
764
765 // Documentation - skip if class has @noRd.
766 // Use class-qualified @name to avoid duplicate \alias{generic} warnings
767 // when multiple S7 classes share the same generic (e.g., get_value on both
768 // S7TraitCounter and CounterTraitS7). The @export is replaced with
769 // @rawNamespace to explicitly export the bare generic name.
770 if !class_has_no_rd {
771 let qualified_name = format!("{}-{}", class_name, generic_name);
772 let method_doc =
773 MethodDocBuilder::new(&class_name, &generic_name, type_ident, &ctx.method.doc_tags)
774 .with_suppress_params()
775 .with_r_name(qualified_name);
776 let mut doc_lines = method_doc.build();
777 doc_lines.push(format!("#' @aliases {}${}", class_name, generic_name));
778 lines.extend(doc_lines);
779 }
780
781 if ctx.has_generic_override() {
782 // Parse "pkg::name" format for external generics
783 let (pkg, gen_name) = if generic_name.contains("::") {
784 let parts: Vec<&str> = generic_name.split("::").collect();
785 (parts[0].to_string(), parts[1].to_string())
786 } else {
787 ("base".to_string(), generic_name.clone())
788 };
789
790 // Use S7::new_external_generic for existing generics from other packages
791 lines.push(format!(
792 "if (!exists(\"{gen_name}\", mode = \"function\")) {{"
793 ));
794 lines.push(format!(
795 " {gen_name} <- S7::new_external_generic(\"{pkg}\", \"{gen_name}\")"
796 ));
797 lines.push("}".to_string());
798
799 // Define method using the resolved generic name
800 let strategy = crate::ReturnStrategy::for_method(ctx.method);
801 let body_lines = crate::MethodReturnBuilder::new(call.clone())
802 .with_strategy(strategy)
803 .with_class_name(class_name.clone())
804 .build_s7_body();
805
806 let what = format!("{}.{}", generic_name, class_name);
807 lines.push(format!(
808 "S7::method({gen_name}, {method_class}) <- function({full_params}) {{"
809 ));
810 ctx.emit_method_prelude(&mut lines, " ", &what);
811 lines.extend(body_lines);
812 lines.push("}".to_string());
813 } else {
814 // Create new S7 generic if it doesn't exist
815 // Use @rawNamespace to explicitly export the bare generic name.
816 // Plain @export would export the qualified @name (e.g., "ClassName-method")
817 // instead of the bare generic.
818 if should_export {
819 lines.push(format!("#' @rawNamespace export({})", generic_name));
820 }
821
822 // Determine dispatch arguments (default: "x", or custom via dispatch = "x,y")
823 let dispatch_args = if let Some(ref dispatch) = method_attrs.s7.dispatch {
824 // Multiple dispatch: "x,y" -> c("x", "y")
825 let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
826 if args.len() == 1 {
827 format!("\"{}\"", args[0])
828 } else {
829 format!(
830 "c({})",
831 args.iter()
832 .map(|a| format!("\"{}\"", a))
833 .collect::<Vec<_>>()
834 .join(", ")
835 )
836 }
837 } else {
838 "\"x\"".to_string()
839 };
840
841 // Determine function signature (with or without ...)
842 let generic_sig = if method_attrs.s7.no_dots {
843 // no_dots: strict generic without ...
844 if let Some(ref dispatch) = method_attrs.s7.dispatch {
845 let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
846 format!("function({}) S7::S7_dispatch()", args.join(", "))
847 } else {
848 "function(x) S7::S7_dispatch()".to_string()
849 }
850 } else {
851 // Default: include ... for extra args
852 if let Some(ref dispatch) = method_attrs.s7.dispatch {
853 let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
854 format!("function({}, ...) S7::S7_dispatch()", args.join(", "))
855 } else {
856 "function(x, ...) S7::S7_dispatch()".to_string()
857 }
858 };
859
860 lines.push(format!(
861 "if (!exists(\"{generic_name}\", mode = \"function\")) {{"
862 ));
863 lines.push(format!(
864 " {generic_name} <- S7::new_generic(\"{generic_name}\", {dispatch_args}, {generic_sig})"
865 ));
866 lines.push("}".to_string());
867
868 // Define method
869 let strategy = crate::ReturnStrategy::for_method(ctx.method);
870 let body_lines = crate::MethodReturnBuilder::new(call)
871 .with_strategy(strategy)
872 .with_class_name(class_name.clone())
873 .build_s7_body();
874
875 // Use matching formals for method (with or without ...)
876 let method_formals = ctx.instance_formals_with_dots(true, !method_attrs.s7.no_dots);
877
878 let what = format!("{}.{}", generic_name, class_name);
879 lines.push(format!(
880 "S7::method({generic_name}, {method_class}) <- function({method_formals}) {{"
881 ));
882 ctx.emit_method_prelude(&mut lines, " ", &what);
883 lines.extend(body_lines);
884 lines.push("}".to_string());
885 }
886 lines.push(String::new());
887
888 // Per-class fast-path dispatch shortcut (#949).
889 //
890 // Alongside the S7 generic, emit a plain non-generic function
891 // `<ClassName>_<method_name>(self, ...)` whose body is identical to the
892 // generic's method but calls `.Call` directly — bypassing
893 // `S7::S7_dispatch()` (class-walk + method-table lookup). On hot loops
894 // this is several times faster than the generic. The receiver is named
895 // `self` here (the generic names it `x`) and is wired through `self@.ptr`.
896 //
897 // Fallback methods dispatch on `S7::class_any`, so a per-class shortcut
898 // is meaningless — skip them. `s7(no_shortcut)` opts a method out
899 // explicitly (e.g. to avoid a name collision with a sidecar accessor).
900 if !method_attrs.s7.fallback && !method_attrs.s7.no_shortcut {
901 let method_name = ctx.method.r_method_name();
902 let shortcut_name = format!("{}_{}", class_name, method_name);
903 let shortcut_call = ctx.instance_call("self@.ptr");
904 let shortcut_formals =
905 ctx.instance_formals_with_receiver("self", !method_attrs.s7.no_dots);
906
907 // Document the shortcut as a standalone function merged onto the
908 // class @rdname page (same shape as static methods). Prepend a
909 // fast-path advisory describing the dispatch bypass and the
910 // subclass-override footgun, then reuse MethodDocBuilder for the
911 // @name / @rdname / @source / @param scaffolding so roxygen2 emits a
912 // complete \usage + \arguments block (no "undocumented argument"
913 // warning). `@param self` is documented explicitly because
914 // MethodDocBuilder skips the receiver.
915 if !class_has_no_rd {
916 lines.extend(shortcut_advisory_lines(&method_name, &class_name));
917 lines.push(format!("#' @param self A `{}` object.", class_name));
918 // `...` appears in the shortcut signature for parity with the S7
919 // generic but MethodDocBuilder doesn't document it, so emit it
920 // here to avoid an "Undocumented arguments" R CMD check warning.
921 if !method_attrs.s7.no_dots {
922 lines.push(
923 "#' @param ... Additional arguments; ignored by the fast-path shortcut."
924 .to_string(),
925 );
926 }
927 // Pass empty doc_tags: the method's own prose (@title/description)
928 // is already rendered on the shared @rdname page by the generic
929 // block above; re-emitting it here would duplicate it. We only
930 // need the @name / @rdname / @source / @param scaffolding so the
931 // shortcut's \usage is fully documented.
932 let mx_doc = ctx.match_arg_doc_placeholders();
933 let no_tags: [String; 0] = [];
934 let method_doc =
935 MethodDocBuilder::new(&class_name, &method_name, type_ident, &no_tags)
936 .with_r_params(&shortcut_formals)
937 .with_match_arg_doc_placeholders(&mx_doc)
938 .with_r_name(shortcut_name.clone());
939 lines.extend(method_doc.build());
940 }
941 // Export the shortcut when the class is exported, so users can reach it.
942 if should_export {
943 lines.push(format!("#' @export {}", shortcut_name));
944 }
945
946 let strategy = crate::ReturnStrategy::for_method(ctx.method);
947 let shortcut_body = crate::MethodReturnBuilder::new(shortcut_call)
948 .with_strategy(strategy)
949 .with_class_name(class_name.clone())
950 .with_chain_var("self".to_string())
951 .build_s7_body();
952
953 let what = format!("{}.{}", generic_name, class_name);
954 lines.push(format!(
955 "{shortcut_name} <- function({shortcut_formals}) {{"
956 ));
957 ctx.emit_method_prelude(&mut lines, " ", &what);
958 lines.extend(shortcut_body);
959 lines.push("}".to_string());
960 lines.push(String::new());
961 }
962 }
963
964 // Static methods as regular functions
965 for ctx in parsed_impl.static_method_contexts() {
966 lines.push(ctx.source_comment(type_ident));
967 let method_name = ctx.method.r_method_name();
968 let fn_name = format!("{}_{}", class_name, method_name);
969
970 // Skip documentation if class has @noRd
971 if !class_has_no_rd {
972 let mx_doc = ctx.match_arg_doc_placeholders();
973 let method_doc =
974 MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
975 .with_r_params(&ctx.params)
976 .with_match_arg_doc_placeholders(&mx_doc)
977 .with_r_name(fn_name.clone());
978 lines.extend(method_doc.build());
979 }
980 // Export static methods so users can call them (if class should be exported)
981 if should_export {
982 lines.push("#' @export".to_string());
983 }
984
985 lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
986
987 ctx.emit_method_prelude(&mut lines, " ", &fn_name);
988
989 let strategy = crate::ReturnStrategy::for_method(ctx.method);
990 let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
991 .with_strategy(strategy)
992 .with_class_name(class_name.clone())
993 .build_s7_inline();
994 lines.push(format!(" {}", return_expr));
995
996 lines.push("}".to_string());
997 lines.push(String::new());
998 }
999
1000 // Phase 4: S7 convert() methods from Rust From/TryFrom patterns
1001 // Convert methods enable type coercion between S7 classes using S7::convert()
1002 //
1003 // Two patterns:
1004 // 1. convert_from = "OtherType" on static method: converts FROM OtherType TO this class
1005 // Rust: fn from_other(other: OtherType) -> Self
1006 // R: S7::method(S7::convert, list(OtherType, ThisClass)) <- function(from, to) ...
1007 //
1008 // 2. convert_to = "OtherType" on instance method: converts FROM this class TO OtherType
1009 // Rust: fn to_other(&self) -> OtherType
1010 // R: S7::method(S7::convert, list(ThisClass, OtherType)) <- function(from, to) ...
1011
1012 for method in &parsed_impl.methods {
1013 if !method.should_include() {
1014 continue;
1015 }
1016 let attrs = &method.method_attrs;
1017
1018 // Handle convert_from (static method pattern)
1019 // S7 convert signature is function(from, to) - one parameter for the source object
1020 if let Some(ref from_type) = attrs.s7.convert_from {
1021 let ctx = MethodContext::new(method, type_ident, parsed_impl.label());
1022
1023 // Documentation for convert method (skip if class has @noRd)
1024 if !class_has_no_rd {
1025 lines.push(format!("#' @name convert-{}-to-{}", from_type, class_name));
1026 lines.push(format!("#' @rdname {}", class_name));
1027 lines.push(crate::roxygen::method_source_tag(type_ident, &method.ident));
1028 // Add @aliases convert so roxygen2 emits \alias{convert} in the
1029 // merged .Rd file. Without this, R CMD check warns:
1030 // "Objects in \usage without \alias in Rd file '...Rd': 'convert'"
1031 lines.push("#' @aliases convert".to_string());
1032 // S7's `convert` generic is `function(from, to, ...)`. Document
1033 // `...` so the rendered \usage{} matches and codoc passes.
1034 lines.push(
1035 "#' @param ... Additional arguments passed to the S7 convert generic."
1036 .to_string(),
1037 );
1038 }
1039
1040 // Generate: S7::method(S7::convert, list(FromType, ThisClass)) <- function(from, to, ...) ...
1041 // The convert_from method takes the source object as its sole parameter
1042 // We pass from@.ptr to extract the ExternalPtr from the S7 object
1043 let call_with_from = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1044 .with_self("from@.ptr")
1045 .build();
1046
1047 let strategy = crate::ReturnStrategy::for_method(method);
1048 let return_expr = crate::MethodReturnBuilder::new(call_with_from)
1049 .with_strategy(strategy)
1050 .with_class_name(class_name.clone())
1051 .build_s7_inline();
1052
1053 // Use imported `convert` - requires `@importFrom S7 convert` in package.
1054 // from_type is a cross-reference → placeholder so the resolver can look it up.
1055 // Method signature includes `...` to match the S7 `convert` generic
1056 // (function(from, to, ...)) and silence R CMD check codoc warnings.
1057 let from_type_ref = class_ref_or_verbatim(from_type);
1058 lines.push(format!(
1059 "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1060 from_type_ref, class_name, return_expr
1061 ));
1062 lines.push(String::new());
1063 }
1064
1065 // Handle convert_to (instance method pattern)
1066 // S7 convert signature is function(from, to) - self becomes from
1067 if let Some(ref to_type) = attrs.s7.convert_to {
1068 let ctx = MethodContext::new(method, type_ident, parsed_impl.label());
1069
1070 // Documentation for convert method (skip if class has @noRd)
1071 if !class_has_no_rd {
1072 lines.push(format!("#' @name convert-{}-to-{}", class_name, to_type));
1073 lines.push(format!("#' @rdname {}", class_name));
1074 lines.push(crate::roxygen::method_source_tag(type_ident, &method.ident));
1075 // Add @aliases convert so roxygen2 emits \alias{convert} in the
1076 // merged .Rd file. Without this, R CMD check warns:
1077 // "Objects in \usage without \alias in Rd file '...Rd': 'convert'"
1078 lines.push("#' @aliases convert".to_string());
1079 // S7's `convert` generic is `function(from, to, ...)`. Document
1080 // `...` so the rendered \usage{} matches and codoc passes.
1081 lines.push(
1082 "#' @param ... Additional arguments passed to the S7 convert generic."
1083 .to_string(),
1084 );
1085 }
1086
1087 // Generate: S7::method(convert, list(ThisClass, ToType)) <- function(from, to, ...) ...
1088 // The convert_to method is an instance method where self is mapped to from@.ptr
1089 let call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1090 .with_self("from@.ptr")
1091 .build();
1092
1093 // to_type is a cross-reference → placeholder for resolver.
1094 // We also pass the placeholder to MethodReturnBuilder so the
1095 // emitted `ToType(.ptr = <result>)` uses the resolved name.
1096 let to_type_ref = class_ref_or_verbatim(to_type);
1097
1098 // Force ReturnSelf strategy for convert methods since they return S7 class types
1099 // that need to be wrapped: ToType(.ptr = <result>)
1100 let return_expr = crate::MethodReturnBuilder::new(call)
1101 .with_strategy(crate::ReturnStrategy::ReturnSelf)
1102 .with_class_name(to_type_ref.clone())
1103 .build_s7_inline();
1104
1105 // Use imported `convert` - requires `@importFrom S7 convert` in package.
1106 // Method signature includes `...` to match the S7 `convert` generic
1107 // (function(from, to, ...)) and silence R CMD check codoc warnings.
1108 lines.push(format!(
1109 "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1110 class_name, to_type_ref, return_expr
1111 ));
1112 lines.push(String::new());
1113 }
1114 }
1115
1116 lines.join("\n")
1117}