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