Skip to main content

alef_e2e/
field_access.rs

1//! Field path resolution for nested struct/map access in e2e assertions.
2//!
3//! The `FieldResolver` maps fixture field paths (e.g., "metadata.title") to
4//! actual API struct paths (e.g., "metadata.document.title") and generates
5//! language-specific accessor expressions.
6
7use alef_codegen::naming::to_go_name;
8use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
9use std::collections::{HashMap, HashSet};
10
11/// Resolves fixture field paths to language-specific accessor expressions.
12pub struct FieldResolver {
13    aliases: HashMap<String, String>,
14    optional_fields: HashSet<String>,
15    result_fields: HashSet<String>,
16    array_fields: HashSet<String>,
17    method_calls: HashSet<String>,
18    /// Aliases for error-path field access (used when assertion_type == "error").
19    /// Maps fixture sub-field names (the part after "error.") to actual field names
20    /// on the error type. E.g., `"status_code" -> "status_code"`.
21    error_field_aliases: HashMap<String, String>,
22    /// Per-type PHP getter classification: maps an owner type's snake_case field
23    /// name to whether THAT field on THAT type requires `->getCamelCase()` syntax
24    /// (because the field's mapped PHP type is non-scalar and ext-php-rs emits a
25    /// `#[php(getter)]` method) rather than `->camelCase` property access.
26    /// Populated by `new_with_php_getters`; empty by default.
27    ///
28    /// Keying by (type, field) — not bare field name — is required because two
29    /// different types can declare the same field name with different scalarness
30    /// (e.g. `CrawlConfig.content: ContentConfig` is non-scalar while
31    /// `MarkdownResult.content: String` is scalar).
32    php_getter_map: PhpGetterMap,
33    /// Per-type Swift first-class/opaque classification, populated by the
34    /// Swift e2e codegen. When non-empty, `accessor` uses
35    /// `render_swift_with_first_class_map` instead of the legacy property-only
36    /// `render_swift_with_optionals`, so paths that traverse from first-class
37    /// types (property access) into opaque typealias types (method-call access)
38    /// pick the correct syntax at each segment.
39    swift_first_class_map: SwiftFirstClassMap,
40}
41
42/// Per-type PHP getter classification + chain-resolution metadata.
43///
44/// Holds enough information to resolve a multi-segment field path through the
45/// IR's nested type graph and pick the correct accessor style at each segment:
46///
47/// * `getters[type_name]` — set of field names on `type_name` whose PHP binding
48///   uses a `#[php(getter)]` method (caller must emit `->getCamelCase()`).
49/// * `field_types[type_name][field_name]` — the IR-resolved `Named` type that
50///   `field_name` traverses into, used to advance the "current type" cursor
51///   for the next path segment. Absent for terminal/scalar fields.
52/// * `root_type` — the IR type name backing the result variable at the start of
53///   any chain. When `None`, chain traversal degrades to per-segment lookup
54///   using a flattened union across all types (legacy bare-name behaviour),
55///   which produces false positives when field names collide across types.
56#[derive(Debug, Clone, Default)]
57pub struct PhpGetterMap {
58    pub getters: HashMap<String, HashSet<String>>,
59    pub field_types: HashMap<String, HashMap<String, String>>,
60    pub root_type: Option<String>,
61    /// All field names per type — used to detect when the recorded `root_type`
62    /// is a misclassification (a workspace-global root_type may not match the
63    /// actual return type of a per-fixture call). When `owner_type` is set but
64    /// `all_fields[owner_type]` doesn't contain `field_name`, the renderer
65    /// falls back to the bare-name union instead of trusting the (wrong) owner.
66    pub all_fields: HashMap<String, HashSet<String>>,
67}
68
69/// Swift first-class struct classification + chain-resolution metadata.
70///
71/// alef-backend-swift emits two flavors of binding types:
72///
73/// * **First-class Codable structs** — `public struct Foo: Codable { public let id: String }`.
74///   Fields are Swift properties; access with `.id` (no parens).
75/// * **Opaque typealiases** — `public typealias Foo = RustBridge.Foo` where the
76///   RustBridge class exposes swift-bridge methods. Fields are methods;
77///   access with `.id()` (parens).
78///
79/// The renderer needs per-segment dispatch because a path can traverse both:
80/// e.g. `BatchListResponse` (first-class Codable, with `data: [BatchObject]`) →
81/// indexed `[0]` → `BatchObject` (opaque typealias). At the `BatchObject` cursor
82/// the renderer must switch to method-call access for `.id`, `.status`, etc.
83///
84/// * `first_class_types` — set of TypeDef names whose binding is a first-class
85///   Codable struct. Membership = "use property access for fields on this type".
86/// * `field_types[type_name][field_name]` — the IR-resolved `Named` type that
87///   `field_name` traverses into.
88/// * `root_type` — the IR type name backing the result variable.
89#[derive(Debug, Clone, Default)]
90pub struct SwiftFirstClassMap {
91    pub first_class_types: HashSet<String>,
92    pub field_types: HashMap<String, HashMap<String, String>>,
93    pub root_type: Option<String>,
94}
95
96impl SwiftFirstClassMap {
97    /// Returns true when fields on `type_name` should be accessed as properties
98    /// (no parens), false when they should be accessed via method-call.
99    ///
100    /// When `type_name` is `None` the renderer defaults to property syntax
101    /// (matching the common case where result types are first-class).
102    pub fn is_first_class(&self, type_name: Option<&str>) -> bool {
103        match type_name {
104            Some(t) => self.first_class_types.contains(t),
105            None => true,
106        }
107    }
108
109    /// Returns the IR `Named` type that `field_name` traverses into for the
110    /// next chain segment, or `None` if the field is terminal/scalar/unknown.
111    pub fn advance(&self, owner_type: Option<&str>, field_name: &str) -> Option<String> {
112        let owner = owner_type?;
113        self.field_types.get(owner).and_then(|m| m.get(field_name).cloned())
114    }
115
116    /// True when no per-type information is recorded.
117    pub fn is_empty(&self) -> bool {
118        self.first_class_types.is_empty() && self.field_types.is_empty()
119    }
120}
121
122impl PhpGetterMap {
123    /// Returns true if `(owner_type, field_name)` requires getter-method syntax.
124    ///
125    /// When `owner_type` is `None` (root type unknown, or chain advanced into an
126    /// unmapped type), falls back to the union across all types: any type
127    /// declaring `field_name` as non-scalar marks it as needing a getter. This
128    /// is the legacy behaviour and is unsafe when field names collide.
129    pub fn needs_getter(&self, owner_type: Option<&str>, field_name: &str) -> bool {
130        if let Some(t) = owner_type {
131            // Only trust the owner-type classification if the type actually declares
132            // this field. A misclassified root_type (workspace-global guess that
133            // doesn't match the per-fixture call's actual return type) shouldn't
134            // shadow the bare-name fallback.
135            let owner_has_field = self.all_fields.get(t).is_some_and(|s| s.contains(field_name));
136            if owner_has_field {
137                if let Some(fields) = self.getters.get(t) {
138                    return fields.contains(field_name);
139                }
140            }
141        }
142        self.getters.values().any(|set| set.contains(field_name))
143    }
144
145    /// Returns the IR `Named` type that `field_name` traverses into for the
146    /// next chain segment, or `None` if the field is terminal/scalar/unknown.
147    pub fn advance(&self, owner_type: Option<&str>, field_name: &str) -> Option<String> {
148        let owner = owner_type?;
149        self.field_types.get(owner).and_then(|m| m.get(field_name).cloned())
150    }
151
152    /// True when no per-type information is recorded — equivalent to the legacy
153    /// "no PHP getter resolution" code path.
154    pub fn is_empty(&self) -> bool {
155        self.getters.is_empty()
156    }
157}
158
159/// A parsed segment of a field path.
160#[derive(Debug, Clone)]
161enum PathSegment {
162    /// Struct field access: `foo`
163    Field(String),
164    /// Array field access with explicit numeric index: `foo[N]`
165    ///
166    /// The `index` is the integer parsed from the bracket (e.g. `choices[2]` → index 2).
167    /// When synthesised by `inject_array_indexing` the index defaults to `0`.
168    ArrayField { name: String, index: usize },
169    /// Map/dict key access: `foo[key]`
170    MapAccess { field: String, key: String },
171    /// Length/count of the preceding collection: `.length`
172    Length,
173}
174
175impl FieldResolver {
176    /// Create a new resolver from the e2e config's `fields` aliases,
177    /// `fields_optional` set, `result_fields` set, `fields_array` set,
178    /// and `fields_method_calls` set.
179    pub fn new(
180        fields: &HashMap<String, String>,
181        optional: &HashSet<String>,
182        result_fields: &HashSet<String>,
183        array_fields: &HashSet<String>,
184        method_calls: &HashSet<String>,
185    ) -> Self {
186        Self {
187            aliases: fields.clone(),
188            optional_fields: optional.clone(),
189            result_fields: result_fields.clone(),
190            array_fields: array_fields.clone(),
191            method_calls: method_calls.clone(),
192            error_field_aliases: HashMap::new(),
193            php_getter_map: PhpGetterMap::default(),
194            swift_first_class_map: SwiftFirstClassMap::default(),
195        }
196    }
197
198    /// Create a new resolver that also includes error-path field aliases.
199    ///
200    /// `error_field_aliases` maps fixture sub-field names (the part after `"error."`)
201    /// to the actual field names on the error type, enabling `accessor_for_error` to
202    /// resolve fields like `"status_code"` against the error value.
203    pub fn new_with_error_aliases(
204        fields: &HashMap<String, String>,
205        optional: &HashSet<String>,
206        result_fields: &HashSet<String>,
207        array_fields: &HashSet<String>,
208        method_calls: &HashSet<String>,
209        error_field_aliases: &HashMap<String, String>,
210    ) -> Self {
211        Self {
212            aliases: fields.clone(),
213            optional_fields: optional.clone(),
214            result_fields: result_fields.clone(),
215            array_fields: array_fields.clone(),
216            method_calls: method_calls.clone(),
217            error_field_aliases: error_field_aliases.clone(),
218            php_getter_map: PhpGetterMap::default(),
219            swift_first_class_map: SwiftFirstClassMap::default(),
220        }
221    }
222
223    /// Create a new resolver that also knows which PHP fields need getter-method syntax.
224    ///
225    /// `php_getter_map` carries a per-`(type_name, field_name)` classification: the PHP
226    /// accessor renderer emits `->getCamelCase()` when `(owner_type, field)` is
227    /// recorded as needing a getter, and `->camelCase` property syntax otherwise.
228    /// This matches the ext-php-rs 0.15.x behaviour where `#[php(getter)]` is used for
229    /// non-scalar fields (Named structs, Vec<Named>, Map, etc.) while `#[php(prop)]` is
230    /// used for scalar-compatible fields.
231    ///
232    /// Keying by (type, field) — not bare field name — is essential because the same
233    /// field name can have different scalarness on different types. The map also carries
234    /// per-type field→nested-type mappings so the renderer can walk a path like
235    /// `outer.inner.content` through the IR, advancing the current-type cursor at each
236    /// segment.
237    pub fn new_with_php_getters(
238        fields: &HashMap<String, String>,
239        optional: &HashSet<String>,
240        result_fields: &HashSet<String>,
241        array_fields: &HashSet<String>,
242        method_calls: &HashSet<String>,
243        error_field_aliases: &HashMap<String, String>,
244        php_getter_map: PhpGetterMap,
245    ) -> Self {
246        Self {
247            aliases: fields.clone(),
248            optional_fields: optional.clone(),
249            result_fields: result_fields.clone(),
250            array_fields: array_fields.clone(),
251            method_calls: method_calls.clone(),
252            error_field_aliases: error_field_aliases.clone(),
253            php_getter_map,
254            swift_first_class_map: SwiftFirstClassMap::default(),
255        }
256    }
257
258    /// Create a new resolver that also knows the Swift first-class/opaque
259    /// classification per IR type. Mirrors `new_with_php_getters` but for the
260    /// Swift `render_swift_with_first_class_map` path.
261    #[allow(clippy::too_many_arguments)]
262    pub fn new_with_swift_first_class(
263        fields: &HashMap<String, String>,
264        optional: &HashSet<String>,
265        result_fields: &HashSet<String>,
266        array_fields: &HashSet<String>,
267        method_calls: &HashSet<String>,
268        error_field_aliases: &HashMap<String, String>,
269        swift_first_class_map: SwiftFirstClassMap,
270    ) -> Self {
271        Self {
272            aliases: fields.clone(),
273            optional_fields: optional.clone(),
274            result_fields: result_fields.clone(),
275            array_fields: array_fields.clone(),
276            method_calls: method_calls.clone(),
277            error_field_aliases: error_field_aliases.clone(),
278            php_getter_map: PhpGetterMap::default(),
279            swift_first_class_map,
280        }
281    }
282
283    /// Resolve a fixture field path to the actual struct path.
284    /// Falls back to the field itself if no alias exists.
285    pub fn resolve<'a>(&'a self, fixture_field: &'a str) -> &'a str {
286        self.aliases
287            .get(fixture_field)
288            .map(String::as_str)
289            .unwrap_or(fixture_field)
290    }
291
292    /// Check if a resolved field path is optional.
293    pub fn is_optional(&self, field: &str) -> bool {
294        if self.optional_fields.contains(field) {
295            return true;
296        }
297        let index_normalized = normalize_numeric_indices(field);
298        if index_normalized != field && self.optional_fields.contains(index_normalized.as_str()) {
299            return true;
300        }
301        // Also check with all numeric indices stripped: "choices[0].message.tool_calls"
302        // should match optional_fields entry "choices.message.tool_calls".
303        let de_indexed = strip_numeric_indices(field);
304        if de_indexed != field && self.optional_fields.contains(de_indexed.as_str()) {
305            return true;
306        }
307        let normalized = field.replace("[].", ".");
308        if normalized != field && self.optional_fields.contains(normalized.as_str()) {
309            return true;
310        }
311        for af in &self.array_fields {
312            if let Some(rest) = field.strip_prefix(af.as_str()) {
313                if let Some(rest) = rest.strip_prefix('.') {
314                    let with_bracket = format!("{af}[].{rest}");
315                    if self.optional_fields.contains(with_bracket.as_str()) {
316                        return true;
317                    }
318                }
319            }
320        }
321        false
322    }
323
324    /// Check if a fixture field has an explicit alias mapping.
325    pub fn has_alias(&self, fixture_field: &str) -> bool {
326        self.aliases.contains_key(fixture_field)
327    }
328
329    /// Check whether `field_name` is configured as an explicit result field.
330    ///
331    /// Returns true only when the caller has populated `result_fields` AND the
332    /// field name is present. Empty `result_fields` always returns false — use
333    /// `is_valid_for_result` for the default-allow semantics.
334    pub fn has_explicit_field(&self, field_name: &str) -> bool {
335        if self.result_fields.is_empty() {
336            return false;
337        }
338        self.result_fields.contains(field_name)
339    }
340
341    /// Check whether a fixture field path is valid for the configured result type.
342    pub fn is_valid_for_result(&self, fixture_field: &str) -> bool {
343        if self.result_fields.is_empty() {
344            return true;
345        }
346        let resolved = self.resolve(fixture_field);
347        let first_segment = resolved.split('.').next().unwrap_or(resolved);
348        let first_segment = first_segment.split('[').next().unwrap_or(first_segment);
349        self.result_fields.contains(first_segment)
350    }
351
352    /// Check if a resolved field is an array/Vec type.
353    pub fn is_array(&self, field: &str) -> bool {
354        self.array_fields.contains(field)
355    }
356
357    /// Check if a field name is the root of a collection type (i.e., the field
358    /// itself returns a `Vec`/array, even though it is not in `fields_array`
359    /// directly).
360    ///
361    /// `fields_array` tracks traversal paths like `choices[0].message.tool_calls`
362    /// — the array element paths — not the bare collection accessor (`choices`).
363    /// `fields_optional` may also contain paths like `data[0].url` that reveal
364    /// `data` is a collection root.
365    ///
366    /// Returns `true` when any entry in `array_fields` or `optional_fields`
367    /// starts with `{field}[`, indicating that `field` is the top-level
368    /// collection getter.
369    pub fn is_collection_root(&self, field: &str) -> bool {
370        let prefix = format!("{field}[");
371        self.array_fields.iter().any(|af| af.starts_with(&prefix))
372            || self.optional_fields.iter().any(|of| of.starts_with(&prefix))
373    }
374
375    /// Check if a resolved field path traverses a tagged-union variant.
376    ///
377    /// Returns `Some((prefix, variant, suffix))` where:
378    /// - `prefix` is the path up to (but not including) the tagged-union field
379    ///   (e.g., `"metadata.format"`)
380    /// - `variant` is the tagged-union accessor segment
381    ///   (e.g., `"excel"`)
382    /// - `suffix` is the remaining path after the variant
383    ///   (e.g., `"sheet_count"`)
384    ///
385    /// Returns `None` if no tagged-union segment exists in the path.
386    pub fn tagged_union_split(&self, fixture_field: &str) -> Option<(String, String, String)> {
387        let resolved = self.resolve(fixture_field);
388        let segments: Vec<&str> = resolved.split('.').collect();
389        let mut path_so_far = String::new();
390        for (i, seg) in segments.iter().enumerate() {
391            if !path_so_far.is_empty() {
392                path_so_far.push('.');
393            }
394            path_so_far.push_str(seg);
395            if self.method_calls.contains(&path_so_far) {
396                // Everything before the last segment of path_so_far is the prefix.
397                let prefix = segments[..i].join(".");
398                let variant = (*seg).to_string();
399                let suffix = segments[i + 1..].join(".");
400                return Some((prefix, variant, suffix));
401            }
402        }
403        None
404    }
405
406    /// Check if a resolved field path contains a non-numeric map access.
407    pub fn has_map_access(&self, fixture_field: &str) -> bool {
408        let resolved = self.resolve(fixture_field);
409        let segments = parse_path(resolved);
410        segments.iter().any(|s| {
411            if let PathSegment::MapAccess { key, .. } = s {
412                !key.chars().all(|c| c.is_ascii_digit())
413            } else {
414                false
415            }
416        })
417    }
418
419    /// Generate a language-specific accessor expression.
420    pub fn accessor(&self, fixture_field: &str, language: &str, result_var: &str) -> String {
421        let resolved = self.resolve(fixture_field);
422        let segments = parse_path(resolved);
423        let segments = self.inject_array_indexing(segments);
424        match language {
425            "java" => render_java_with_optionals(&segments, result_var, &self.optional_fields),
426            "kotlin" => render_kotlin_with_optionals(&segments, result_var, &self.optional_fields),
427            // kotlin_android data classes expose fields as Kotlin properties (no parens),
428            // not as Java-style getter methods. Use the dedicated renderer.
429            "kotlin_android" => render_kotlin_android_with_optionals(&segments, result_var, &self.optional_fields),
430            "rust" => render_rust_with_optionals(&segments, result_var, &self.optional_fields, &self.method_calls),
431            "csharp" => render_csharp_with_optionals(&segments, result_var, &self.optional_fields),
432            "zig" => render_zig_with_optionals(&segments, result_var, &self.optional_fields, &self.method_calls),
433            "swift" if !self.swift_first_class_map.is_empty() => render_swift_with_first_class_map(
434                &segments,
435                result_var,
436                &self.optional_fields,
437                &self.swift_first_class_map,
438            ),
439            "swift" => render_swift_with_optionals(&segments, result_var, &self.optional_fields),
440            "dart" => render_dart_with_optionals(&segments, result_var, &self.optional_fields),
441            "php" if !self.php_getter_map.is_empty() => {
442                render_php_with_getters(&segments, result_var, &self.php_getter_map)
443            }
444            _ => render_accessor(&segments, language, result_var),
445        }
446    }
447
448    /// Generate a language-specific accessor expression for an error-path field.
449    ///
450    /// Used when `assertion_type == "error"` and the fixture declares a `field`
451    /// like `"error.status_code"`. The caller strips the `"error."` prefix and
452    /// passes the sub-field name (e.g. `"status_code"`) here.
453    ///
454    /// Resolves against `error_field_aliases` (instead of the success-path
455    /// `aliases`). Falls back to direct field access (i.e. `err_var.status_code`)
456    /// when no alias exists.
457    ///
458    /// For Rust, uses `render_rust_with_optionals` so that fields in
459    /// `method_calls` emit parentheses (e.g. `err.status_code()` when
460    /// `"status_code"` is in `fields_method_calls`).
461    pub fn accessor_for_error(&self, sub_field: &str, language: &str, err_var: &str) -> String {
462        let resolved = self
463            .error_field_aliases
464            .get(sub_field)
465            .map(String::as_str)
466            .unwrap_or(sub_field);
467        let segments = parse_path(resolved);
468        // Error fields are simple scalar fields — no array injection needed.
469        // For Rust, delegate to render_rust_with_optionals so method_calls are honoured.
470        match language {
471            "rust" => render_rust_with_optionals(&segments, err_var, &self.optional_fields, &self.method_calls),
472            _ => render_accessor(&segments, language, err_var),
473        }
474    }
475
476    /// Check whether a sub-field (the part after `"error."`) has an entry in
477    /// `error_field_aliases` or if there are any error aliases at all.
478    ///
479    /// When there are no error aliases configured, callers fall back to
480    /// direct field access, which is the safe default for known public fields
481    /// like `status_code` on `LiterLlmError`.
482    pub fn has_error_aliases(&self) -> bool {
483        !self.error_field_aliases.is_empty()
484    }
485
486    fn inject_array_indexing(&self, segments: Vec<PathSegment>) -> Vec<PathSegment> {
487        if self.array_fields.is_empty() {
488            return segments;
489        }
490        let len = segments.len();
491        let mut result = Vec::with_capacity(len);
492        let mut path_so_far = String::new();
493        for i in 0..len {
494            let seg = &segments[i];
495            match seg {
496                PathSegment::Field(f) => {
497                    if !path_so_far.is_empty() {
498                        path_so_far.push('.');
499                    }
500                    path_so_far.push_str(f);
501                    let next_is_length = i + 1 < len && matches!(segments[i + 1], PathSegment::Length);
502                    if i + 1 < len && self.array_fields.contains(&path_so_far) && !next_is_length {
503                        // Config-registered array field without explicit index — default to 0.
504                        result.push(PathSegment::ArrayField {
505                            name: f.clone(),
506                            index: 0,
507                        });
508                    } else {
509                        result.push(seg.clone());
510                    }
511                }
512                // Explicit ArrayField from parse_path — pass through unchanged; the user's
513                // explicit index takes precedence over any config default.
514                PathSegment::ArrayField { .. } => {
515                    result.push(seg.clone());
516                }
517                PathSegment::MapAccess { field, key } => {
518                    if !path_so_far.is_empty() {
519                        path_so_far.push('.');
520                    }
521                    path_so_far.push_str(field);
522                    let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
523                    if is_numeric && self.array_fields.contains(&path_so_far) {
524                        // Numeric map-access on a registered array field — upgrade to ArrayField.
525                        let index: usize = key.parse().unwrap_or(0);
526                        result.push(PathSegment::ArrayField {
527                            name: field.clone(),
528                            index,
529                        });
530                    } else {
531                        result.push(seg.clone());
532                    }
533                }
534                _ => {
535                    result.push(seg.clone());
536                }
537            }
538        }
539        result
540    }
541
542    /// Generate a Rust variable binding that unwraps an Optional string field.
543    pub fn rust_unwrap_binding(&self, fixture_field: &str, result_var: &str) -> Option<(String, String)> {
544        let resolved = self.resolve(fixture_field);
545        if !self.is_optional(resolved) {
546            return None;
547        }
548        let segments = parse_path(resolved);
549        let segments = self.inject_array_indexing(segments);
550        // Sanitize the resolved path into a snake_case Rust identifier:
551        // 1. `.` and `[` become `_` separators, `]` is dropped.
552        // 2. Collapse runs of `_` so `foo[].bar` → `foo__bar` → `foo_bar`
553        //    and strip any leading/trailing underscores.
554        let local_var = {
555            let raw = resolved.replace(['.', '['], "_").replace(']', "");
556            let mut collapsed = String::with_capacity(raw.len());
557            let mut prev_underscore = false;
558            for ch in raw.chars() {
559                if ch == '_' {
560                    if !prev_underscore {
561                        collapsed.push('_');
562                    }
563                    prev_underscore = true;
564                } else {
565                    collapsed.push(ch);
566                    prev_underscore = false;
567                }
568            }
569            collapsed.trim_matches('_').to_string()
570        };
571        let accessor = render_accessor(&segments, "rust", result_var);
572        let has_map_access = segments.iter().any(|s| {
573            if let PathSegment::MapAccess { key, .. } = s {
574                !key.chars().all(|c| c.is_ascii_digit())
575            } else {
576                false
577            }
578        });
579        let is_array = self.is_array(resolved);
580        let binding = if has_map_access {
581            format!("let {local_var} = {accessor}.unwrap_or(\"\");")
582        } else if is_array {
583            format!("let {local_var} = {accessor}.as_deref().unwrap_or(&[]);")
584        } else {
585            format!("let {local_var} = {accessor}.as_ref().map(|v| v.to_string()).unwrap_or_default();")
586        };
587        Some((binding, local_var))
588    }
589}
590
591/// Strip all numeric indices from a path so `"choices[0].message.tool_calls"` →
592/// `"choices.message.tool_calls"`. Used by `is_optional` to match entries like
593/// `"choices.message.tool_calls"` in `optional_fields` when the caller supplies a
594/// path that includes a concrete index.
595fn strip_numeric_indices(path: &str) -> String {
596    let mut result = String::with_capacity(path.len());
597    let mut chars = path.chars().peekable();
598    while let Some(c) = chars.next() {
599        if c == '[' {
600            let mut key = String::new();
601            let mut closed = false;
602            for inner in chars.by_ref() {
603                if inner == ']' {
604                    closed = true;
605                    break;
606                }
607                key.push(inner);
608            }
609            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
610                // Numeric index — drop it entirely (including any trailing dot).
611            } else {
612                result.push('[');
613                result.push_str(&key);
614                if closed {
615                    result.push(']');
616                }
617            }
618        } else {
619            result.push(c);
620        }
621    }
622    // Collapse any double-dots introduced by dropping `[N].` sequences.
623    while result.contains("..") {
624        result = result.replace("..", ".");
625    }
626    if result.starts_with('.') {
627        result.remove(0);
628    }
629    result
630}
631
632fn normalize_numeric_indices(path: &str) -> String {
633    let mut result = String::with_capacity(path.len());
634    let mut chars = path.chars().peekable();
635    while let Some(c) = chars.next() {
636        if c == '[' {
637            let mut key = String::new();
638            let mut closed = false;
639            for inner in chars.by_ref() {
640                if inner == ']' {
641                    closed = true;
642                    break;
643                }
644                key.push(inner);
645            }
646            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
647                result.push_str("[0]");
648            } else {
649                result.push('[');
650                result.push_str(&key);
651                if closed {
652                    result.push(']');
653                }
654            }
655        } else {
656            result.push(c);
657        }
658    }
659    result
660}
661
662fn parse_path(path: &str) -> Vec<PathSegment> {
663    let mut segments = Vec::new();
664    for part in path.split('.') {
665        if part == "length" || part == "count" || part == "size" {
666            segments.push(PathSegment::Length);
667        } else if let Some(bracket_pos) = part.find('[') {
668            let name = part[..bracket_pos].to_string();
669            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
670            if key.is_empty() {
671                // `foo[]` — bare array bracket, index defaults to 0 (upgraded by inject_array_indexing).
672                segments.push(PathSegment::ArrayField { name, index: 0 });
673            } else if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
674                // `foo[N]` — user-typed explicit numeric index.
675                let index: usize = key.parse().unwrap_or(0);
676                segments.push(PathSegment::ArrayField { name, index });
677            } else {
678                // `foo[key]` — string-keyed map access.
679                segments.push(PathSegment::MapAccess { field: name, key });
680            }
681        } else {
682            segments.push(PathSegment::Field(part.to_string()));
683        }
684    }
685    segments
686}
687
688fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
689    match language {
690        "rust" => render_rust(segments, result_var),
691        "python" => render_dot_access(segments, result_var, "python"),
692        "typescript" | "node" => render_typescript(segments, result_var),
693        "wasm" => render_wasm(segments, result_var),
694        "go" => render_go(segments, result_var),
695        "java" => render_java(segments, result_var),
696        "kotlin" => render_kotlin(segments, result_var),
697        "kotlin_android" => render_kotlin_android(segments, result_var),
698        "csharp" => render_pascal_dot(segments, result_var),
699        "ruby" => render_dot_access(segments, result_var, "ruby"),
700        "php" => render_php(segments, result_var),
701        "elixir" => render_dot_access(segments, result_var, "elixir"),
702        "r" => render_r(segments, result_var),
703        "c" => render_c(segments, result_var),
704        "swift" => render_swift(segments, result_var),
705        "dart" => render_dart(segments, result_var),
706        _ => render_dot_access(segments, result_var, language),
707    }
708}
709
710/// Generate a Swift accessor expression.
711///
712/// Alef now emits first-class Swift structs (`public struct Foo: Codable { public let
713/// id: String }`) for most DTO types, where fields are properties — property access
714/// uses `.id` (no parens). The remaining typealias-to-opaque types (e.g. request
715/// types with Vec/Map/Named fields that aren't first-class candidates) are accessed
716/// via the swift-bridge-generated method-call syntax `.id()`, but in e2e tests these
717/// typealias types are method inputs / streaming outputs rather than parents for
718/// field-access chains, so property syntax works in practice. If a future e2e test
719/// asserts on a field-access chain rooted in an opaque type, a per-type
720/// `SwiftFirstClassMap` (analogous to `PhpGetterMap`) would be needed.
721fn render_swift(segments: &[PathSegment], result_var: &str) -> String {
722    let mut out = result_var.to_string();
723    for seg in segments {
724        match seg {
725            PathSegment::Field(f) => {
726                out.push('.');
727                out.push_str(f);
728            }
729            PathSegment::ArrayField { name, index } => {
730                out.push('.');
731                out.push_str(name);
732                out.push_str(&format!("[{index}]"));
733            }
734            PathSegment::MapAccess { field, key } => {
735                out.push('.');
736                out.push_str(field);
737                if key.chars().all(|c| c.is_ascii_digit()) {
738                    out.push_str(&format!("[{key}]"));
739                } else {
740                    out.push_str(&format!("[\"{key}\"]"));
741                }
742            }
743            PathSegment::Length => {
744                out.push_str(".count");
745            }
746        }
747    }
748    out
749}
750
751/// Generate a Swift accessor expression with optional chaining.
752///
753/// When an intermediate field is in `optional_fields`, a `?` is inserted after the
754/// `()` call on that segment so the next access uses `?.`. This prevents compile
755/// errors when accessing members through an `Optional<T>` in Swift.
756///
757/// Example: for `metadata.format.excel.sheet_count` where `metadata.format` and
758/// `metadata.format.excel` are optional, the result is:
759/// `result.metadata().format()?.excel()?.sheet_count()`
760fn render_swift_with_optionals(
761    segments: &[PathSegment],
762    result_var: &str,
763    optional_fields: &HashSet<String>,
764) -> String {
765    let mut out = result_var.to_string();
766    let mut path_so_far = String::new();
767    let total = segments.len();
768    for (i, seg) in segments.iter().enumerate() {
769        let is_leaf = i == total - 1;
770        match seg {
771            PathSegment::Field(f) => {
772                if !path_so_far.is_empty() {
773                    path_so_far.push('.');
774                }
775                path_so_far.push_str(f);
776                out.push('.');
777                out.push_str(f);
778                // First-class Swift struct fields are properties (no parens).
779                // Insert `?` after the property name for non-leaf optional fields so the
780                // next member access becomes `?.`.
781                if !is_leaf && optional_fields.contains(&path_so_far) {
782                    out.push('?');
783                }
784            }
785            PathSegment::ArrayField { name, index } => {
786                if !path_so_far.is_empty() {
787                    path_so_far.push('.');
788                }
789                path_so_far.push_str(name);
790                let is_optional = optional_fields.contains(&path_so_far);
791                out.push('.');
792                out.push_str(name);
793                if is_optional {
794                    // Optional<[T]>: unwrap before indexing.
795                    out.push_str(&format!("?[{index}]"));
796                } else {
797                    out.push_str(&format!("[{index}]"));
798                }
799                path_so_far.push_str("[0]");
800                let _ = is_leaf;
801            }
802            PathSegment::MapAccess { field, key } => {
803                if !path_so_far.is_empty() {
804                    path_so_far.push('.');
805                }
806                path_so_far.push_str(field);
807                out.push('.');
808                out.push_str(field);
809                if key.chars().all(|c| c.is_ascii_digit()) {
810                    out.push_str(&format!("[{key}]"));
811                } else {
812                    out.push_str(&format!("[\"{key}\"]"));
813                }
814            }
815            PathSegment::Length => {
816                out.push_str(".count");
817            }
818        }
819    }
820    out
821}
822
823/// Like `render_swift_with_optionals` but dispatches per-segment between
824/// property access (first-class Codable struct) and method-call access
825/// (typealias-to-opaque RustBridge class). Uses the `SwiftFirstClassMap` to
826/// track the current type as the path advances.
827fn render_swift_with_first_class_map(
828    segments: &[PathSegment],
829    result_var: &str,
830    optional_fields: &HashSet<String>,
831    map: &SwiftFirstClassMap,
832) -> String {
833    let mut out = result_var.to_string();
834    let mut path_so_far = String::new();
835    let mut current_type: Option<String> = map.root_type.clone();
836    let total = segments.len();
837    for (i, seg) in segments.iter().enumerate() {
838        let is_leaf = i == total - 1;
839        let property_syntax = map.is_first_class(current_type.as_deref());
840        match seg {
841            PathSegment::Field(f) => {
842                if !path_so_far.is_empty() {
843                    path_so_far.push('.');
844                }
845                path_so_far.push_str(f);
846                out.push('.');
847                out.push_str(f);
848                if !property_syntax {
849                    out.push_str("()");
850                }
851                if !is_leaf && optional_fields.contains(&path_so_far) {
852                    out.push('?');
853                }
854                current_type = map.advance(current_type.as_deref(), f);
855            }
856            PathSegment::ArrayField { name, index } => {
857                if !path_so_far.is_empty() {
858                    path_so_far.push('.');
859                }
860                path_so_far.push_str(name);
861                let is_optional = optional_fields.contains(&path_so_far);
862                out.push('.');
863                out.push_str(name);
864                let access = if property_syntax { "" } else { "()" };
865                if is_optional {
866                    out.push_str(&format!("{access}?[{index}]"));
867                } else {
868                    out.push_str(&format!("{access}[{index}]"));
869                }
870                path_so_far.push_str("[0]");
871                // Indexing into a Vec<Named> yields a Named element — advance current_type.
872                current_type = map.advance(current_type.as_deref(), name);
873            }
874            PathSegment::MapAccess { field, key } => {
875                if !path_so_far.is_empty() {
876                    path_so_far.push('.');
877                }
878                path_so_far.push_str(field);
879                out.push('.');
880                out.push_str(field);
881                let access = if property_syntax { "" } else { "()" };
882                if key.chars().all(|c| c.is_ascii_digit()) {
883                    out.push_str(&format!("{access}[{key}]"));
884                } else {
885                    out.push_str(&format!("{access}[\"{key}\"]"));
886                }
887                current_type = map.advance(current_type.as_deref(), field);
888            }
889            PathSegment::Length => {
890                out.push_str(".count");
891            }
892        }
893    }
894    out
895}
896
897fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
898    let mut out = result_var.to_string();
899    for seg in segments {
900        match seg {
901            PathSegment::Field(f) => {
902                out.push('.');
903                out.push_str(&f.to_snake_case());
904            }
905            PathSegment::ArrayField { name, index } => {
906                out.push('.');
907                out.push_str(&name.to_snake_case());
908                out.push_str(&format!("[{index}]"));
909            }
910            PathSegment::MapAccess { field, key } => {
911                out.push('.');
912                out.push_str(&field.to_snake_case());
913                if key.chars().all(|c| c.is_ascii_digit()) {
914                    out.push_str(&format!("[{key}]"));
915                } else {
916                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
917                }
918            }
919            PathSegment::Length => {
920                out.push_str(".len()");
921            }
922        }
923    }
924    out
925}
926
927fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
928    let mut out = result_var.to_string();
929    for seg in segments {
930        match seg {
931            PathSegment::Field(f) => {
932                out.push('.');
933                out.push_str(f);
934            }
935            PathSegment::ArrayField { name, index } => {
936                if language == "elixir" {
937                    let current = std::mem::take(&mut out);
938                    out = format!("Enum.at({current}.{name}, {index})");
939                } else {
940                    out.push('.');
941                    out.push_str(name);
942                    out.push_str(&format!("[{index}]"));
943                }
944            }
945            PathSegment::MapAccess { field, key } => {
946                let is_numeric = key.chars().all(|c| c.is_ascii_digit());
947                if is_numeric && language == "elixir" {
948                    let current = std::mem::take(&mut out);
949                    out = format!("Enum.at({current}.{field}, {key})");
950                } else {
951                    out.push('.');
952                    out.push_str(field);
953                    if is_numeric {
954                        let idx: usize = key.parse().unwrap_or(0);
955                        out.push_str(&format!("[{idx}]"));
956                    } else if language == "elixir" || language == "ruby" {
957                        // Ruby/Elixir hashes use `["key"]` bracket access (Ruby's Hash has
958                        // no `get` method; Elixir maps use bracket access too).
959                        out.push_str(&format!("[\"{key}\"]"));
960                    } else {
961                        out.push_str(&format!(".get(\"{key}\")"));
962                    }
963                }
964            }
965            PathSegment::Length => match language {
966                "ruby" => out.push_str(".length"),
967                "elixir" => {
968                    let current = std::mem::take(&mut out);
969                    out = format!("length({current})");
970                }
971                "gleam" => {
972                    let current = std::mem::take(&mut out);
973                    out = format!("list.length({current})");
974                }
975                _ => {
976                    let current = std::mem::take(&mut out);
977                    out = format!("len({current})");
978                }
979            },
980        }
981    }
982    out
983}
984
985fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
986    let mut out = result_var.to_string();
987    for seg in segments {
988        match seg {
989            PathSegment::Field(f) => {
990                out.push('.');
991                out.push_str(&f.to_lower_camel_case());
992            }
993            PathSegment::ArrayField { name, index } => {
994                out.push('.');
995                out.push_str(&name.to_lower_camel_case());
996                out.push_str(&format!("[{index}]"));
997            }
998            PathSegment::MapAccess { field, key } => {
999                out.push('.');
1000                out.push_str(&field.to_lower_camel_case());
1001                // Numeric (digit-only) keys index into arrays as integers, not as
1002                // string-keyed object properties; emit `[0]` not `["0"]`.
1003                if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
1004                    out.push_str(&format!("[{key}]"));
1005                } else {
1006                    out.push_str(&format!("[\"{key}\"]"));
1007                }
1008            }
1009            PathSegment::Length => {
1010                out.push_str(".length");
1011            }
1012        }
1013    }
1014    out
1015}
1016
1017fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
1018    let mut out = result_var.to_string();
1019    for seg in segments {
1020        match seg {
1021            PathSegment::Field(f) => {
1022                out.push('.');
1023                out.push_str(&f.to_lower_camel_case());
1024            }
1025            PathSegment::ArrayField { name, index } => {
1026                out.push('.');
1027                out.push_str(&name.to_lower_camel_case());
1028                out.push_str(&format!("[{index}]"));
1029            }
1030            PathSegment::MapAccess { field, key } => {
1031                out.push('.');
1032                out.push_str(&field.to_lower_camel_case());
1033                out.push_str(&format!(".get(\"{key}\")"));
1034            }
1035            PathSegment::Length => {
1036                out.push_str(".length");
1037            }
1038        }
1039    }
1040    out
1041}
1042
1043fn render_go(segments: &[PathSegment], result_var: &str) -> String {
1044    let mut out = result_var.to_string();
1045    for seg in segments {
1046        match seg {
1047            PathSegment::Field(f) => {
1048                out.push('.');
1049                out.push_str(&to_go_name(f));
1050            }
1051            PathSegment::ArrayField { name, index } => {
1052                out.push('.');
1053                out.push_str(&to_go_name(name));
1054                out.push_str(&format!("[{index}]"));
1055            }
1056            PathSegment::MapAccess { field, key } => {
1057                out.push('.');
1058                out.push_str(&to_go_name(field));
1059                if key.chars().all(|c| c.is_ascii_digit()) {
1060                    out.push_str(&format!("[{key}]"));
1061                } else {
1062                    out.push_str(&format!("[\"{key}\"]"));
1063                }
1064            }
1065            PathSegment::Length => {
1066                let current = std::mem::take(&mut out);
1067                out = format!("len({current})");
1068            }
1069        }
1070    }
1071    out
1072}
1073
1074fn render_java(segments: &[PathSegment], result_var: &str) -> String {
1075    let mut out = result_var.to_string();
1076    for seg in segments {
1077        match seg {
1078            PathSegment::Field(f) => {
1079                out.push('.');
1080                out.push_str(&f.to_lower_camel_case());
1081                out.push_str("()");
1082            }
1083            PathSegment::ArrayField { name, index } => {
1084                out.push('.');
1085                out.push_str(&name.to_lower_camel_case());
1086                out.push_str(&format!("().get({index})"));
1087            }
1088            PathSegment::MapAccess { field, key } => {
1089                out.push('.');
1090                out.push_str(&field.to_lower_camel_case());
1091                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1092                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1093                if is_numeric {
1094                    out.push_str(&format!("().get({key})"));
1095                } else {
1096                    out.push_str(&format!("().get(\"{key}\")"));
1097                }
1098            }
1099            PathSegment::Length => {
1100                out.push_str(".size()");
1101            }
1102        }
1103    }
1104    out
1105}
1106
1107/// Wrap a Kotlin getter name in backticks when it collides with a Kotlin hard keyword.
1108///
1109/// Hard keywords cannot be used as identifiers without escaping, so `result.object()`
1110/// is a syntax error; `` result.`object`() `` is the legal form.
1111fn kotlin_getter(name: &str) -> String {
1112    let camel = name.to_lower_camel_case();
1113    match camel.as_str() {
1114        "as" | "break" | "class" | "continue" | "do" | "else" | "false" | "for" | "fun" | "if" | "in" | "interface"
1115        | "is" | "null" | "object" | "package" | "return" | "super" | "this" | "throw" | "true" | "try"
1116        | "typealias" | "typeof" | "val" | "var" | "when" | "while" => format!("`{camel}`"),
1117        _ => camel,
1118    }
1119}
1120
1121fn render_kotlin(segments: &[PathSegment], result_var: &str) -> String {
1122    let mut out = result_var.to_string();
1123    for seg in segments {
1124        match seg {
1125            PathSegment::Field(f) => {
1126                out.push('.');
1127                out.push_str(&kotlin_getter(f));
1128                out.push_str("()");
1129            }
1130            PathSegment::ArrayField { name, index } => {
1131                out.push('.');
1132                out.push_str(&kotlin_getter(name));
1133                if *index == 0 {
1134                    out.push_str("().first()");
1135                } else {
1136                    out.push_str(&format!("().get({index})"));
1137                }
1138            }
1139            PathSegment::MapAccess { field, key } => {
1140                out.push('.');
1141                out.push_str(&kotlin_getter(field));
1142                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1143                if is_numeric {
1144                    out.push_str(&format!("().get({key})"));
1145                } else {
1146                    out.push_str(&format!("().get(\"{key}\")"));
1147                }
1148            }
1149            PathSegment::Length => {
1150                out.push_str(".size");
1151            }
1152        }
1153    }
1154    out
1155}
1156
1157fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1158    let mut out = result_var.to_string();
1159    let mut path_so_far = String::new();
1160    for (i, seg) in segments.iter().enumerate() {
1161        let is_leaf = i == segments.len() - 1;
1162        match seg {
1163            PathSegment::Field(f) => {
1164                if !path_so_far.is_empty() {
1165                    path_so_far.push('.');
1166                }
1167                path_so_far.push_str(f);
1168                out.push('.');
1169                out.push_str(&f.to_lower_camel_case());
1170                out.push_str("()");
1171                let _ = is_leaf;
1172                let _ = optional_fields;
1173            }
1174            PathSegment::ArrayField { name, index } => {
1175                if !path_so_far.is_empty() {
1176                    path_so_far.push('.');
1177                }
1178                path_so_far.push_str(name);
1179                out.push('.');
1180                out.push_str(&name.to_lower_camel_case());
1181                out.push_str(&format!("().get({index})"));
1182            }
1183            PathSegment::MapAccess { field, key } => {
1184                if !path_so_far.is_empty() {
1185                    path_so_far.push('.');
1186                }
1187                path_so_far.push_str(field);
1188                out.push('.');
1189                out.push_str(&field.to_lower_camel_case());
1190                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1191                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1192                if is_numeric {
1193                    out.push_str(&format!("().get({key})"));
1194                } else {
1195                    out.push_str(&format!("().get(\"{key}\")"));
1196                }
1197            }
1198            PathSegment::Length => {
1199                out.push_str(".size()");
1200            }
1201        }
1202    }
1203    out
1204}
1205
1206/// Kotlin variant of `render_java_with_optionals` using Kotlin idioms.
1207///
1208/// When the previous field in the chain is optional (nullable), uses `?.`
1209/// safe-call navigation for the next segment so the Kotlin compiler is
1210/// satisfied by the nullable receiver.
1211///
1212/// Nullability is **sticky**: once a `?.` safe-call has been emitted for any
1213/// segment, all subsequent segments also use `?.` because they operate on a
1214/// nullable receiver. A non-optional field after a `?.` call still returns
1215/// `T?` (because the whole chain can be null if any prefix was null).
1216///
1217/// Example: for `toolCalls[0].function.name` where `toolCalls` is optional:
1218/// `result.toolCalls()?.first()?.function()?.name()` — even though `function`
1219/// and `name` are themselves non-optional, they follow a `?.` chain.
1220fn render_kotlin_with_optionals(
1221    segments: &[PathSegment],
1222    result_var: &str,
1223    optional_fields: &HashSet<String>,
1224) -> String {
1225    let mut out = result_var.to_string();
1226    let mut path_so_far = String::new();
1227    // Track whether the previous segment returned a nullable type. Starts
1228    // false because `result_var` is always non-null.
1229    //
1230    // This flag is sticky: once set to true it stays true for the rest of
1231    // the chain because a `?.` call returns `T?` regardless of whether the
1232    // subsequent field itself is declared optional. All accesses on a
1233    // nullable receiver must also use `?.`.
1234    let mut prev_was_nullable = false;
1235    for seg in segments {
1236        let nav = if prev_was_nullable { "?." } else { "." };
1237        match seg {
1238            PathSegment::Field(f) => {
1239                if !path_so_far.is_empty() {
1240                    path_so_far.push('.');
1241                }
1242                path_so_far.push_str(f);
1243                // After this call, the receiver is nullable if the field is in
1244                // optional_fields (the Java @Nullable annotation makes the
1245                // return type T? in Kotlin) OR if the incoming receiver was
1246                // already nullable (sticky: `?.` call yields `T?`).
1247                let is_optional = optional_fields.contains(&path_so_far);
1248                out.push_str(nav);
1249                out.push_str(&kotlin_getter(f));
1250                out.push_str("()");
1251                prev_was_nullable = prev_was_nullable || is_optional;
1252            }
1253            PathSegment::ArrayField { name, index } => {
1254                if !path_so_far.is_empty() {
1255                    path_so_far.push('.');
1256                }
1257                path_so_far.push_str(name);
1258                let is_optional = optional_fields.contains(&path_so_far);
1259                out.push_str(nav);
1260                out.push_str(&kotlin_getter(name));
1261                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1262                if *index == 0 {
1263                    out.push_str(&format!("(){safe}.first()"));
1264                } else {
1265                    out.push_str(&format!("(){safe}.get({index})"));
1266                }
1267                // Record the "[0]" suffix so subsequent optional-field checks against
1268                // paths like "choices[0].message.tool_calls" continue to match when the
1269                // optional_fields set uses indexed keys (mirrors the Rust renderer).
1270                path_so_far.push_str("[0]");
1271                prev_was_nullable = prev_was_nullable || is_optional;
1272            }
1273            PathSegment::MapAccess { field, key } => {
1274                if !path_so_far.is_empty() {
1275                    path_so_far.push('.');
1276                }
1277                path_so_far.push_str(field);
1278                let is_optional = optional_fields.contains(&path_so_far);
1279                out.push_str(nav);
1280                out.push_str(&kotlin_getter(field));
1281                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1282                if is_numeric {
1283                    if prev_was_nullable || is_optional {
1284                        out.push_str(&format!("()?.get({key})"));
1285                    } else {
1286                        out.push_str(&format!("().get({key})"));
1287                    }
1288                } else if prev_was_nullable || is_optional {
1289                    out.push_str(&format!("()?.get(\"{key}\")"));
1290                } else {
1291                    out.push_str(&format!("().get(\"{key}\")"));
1292                }
1293                prev_was_nullable = prev_was_nullable || is_optional;
1294            }
1295            PathSegment::Length => {
1296                // .size is a Kotlin property, no () needed.
1297                // If the previous field was nullable, use ?.size
1298                let size_nav = if prev_was_nullable { "?" } else { "" };
1299                out.push_str(&format!("{size_nav}.size"));
1300                prev_was_nullable = false;
1301            }
1302        }
1303    }
1304    out
1305}
1306
1307/// kotlin_android variant of `render_kotlin_with_optionals`.
1308///
1309/// kotlin_android generates Kotlin data classes whose fields are Kotlin
1310/// **properties** (not Java-style getter methods). Every field segment must
1311/// therefore be accessed without parentheses: `result.choices.first().message.content`
1312/// rather than `result.choices().first().message().content()`.
1313///
1314/// The nullable-chain rules are identical to `render_kotlin_with_optionals`:
1315/// once a segment in the path is optional (`T?`) the remainder of the chain
1316/// uses `?.` safe-call syntax.
1317fn render_kotlin_android_with_optionals(
1318    segments: &[PathSegment],
1319    result_var: &str,
1320    optional_fields: &HashSet<String>,
1321) -> String {
1322    let mut out = result_var.to_string();
1323    let mut path_so_far = String::new();
1324    let mut prev_was_nullable = false;
1325    for seg in segments {
1326        let nav = if prev_was_nullable { "?." } else { "." };
1327        match seg {
1328            PathSegment::Field(f) => {
1329                if !path_so_far.is_empty() {
1330                    path_so_far.push('.');
1331                }
1332                path_so_far.push_str(f);
1333                let is_optional = optional_fields.contains(&path_so_far);
1334                out.push_str(nav);
1335                // Property access — no () suffix.
1336                out.push_str(&kotlin_getter(f));
1337                prev_was_nullable = prev_was_nullable || is_optional;
1338            }
1339            PathSegment::ArrayField { name, index } => {
1340                if !path_so_far.is_empty() {
1341                    path_so_far.push('.');
1342                }
1343                path_so_far.push_str(name);
1344                let is_optional = optional_fields.contains(&path_so_far);
1345                out.push_str(nav);
1346                // Property access — no () suffix on the collection itself.
1347                out.push_str(&kotlin_getter(name));
1348                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1349                if *index == 0 {
1350                    out.push_str(&format!("{safe}.first()"));
1351                } else {
1352                    out.push_str(&format!("{safe}.get({index})"));
1353                }
1354                path_so_far.push_str("[0]");
1355                prev_was_nullable = prev_was_nullable || is_optional;
1356            }
1357            PathSegment::MapAccess { field, key } => {
1358                if !path_so_far.is_empty() {
1359                    path_so_far.push('.');
1360                }
1361                path_so_far.push_str(field);
1362                let is_optional = optional_fields.contains(&path_so_far);
1363                out.push_str(nav);
1364                // Property access — no () suffix on the map field.
1365                out.push_str(&kotlin_getter(field));
1366                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1367                if is_numeric {
1368                    if prev_was_nullable || is_optional {
1369                        out.push_str(&format!("?.get({key})"));
1370                    } else {
1371                        out.push_str(&format!(".get({key})"));
1372                    }
1373                } else if prev_was_nullable || is_optional {
1374                    out.push_str(&format!("?.get(\"{key}\")"));
1375                } else {
1376                    out.push_str(&format!(".get(\"{key}\")"));
1377                }
1378                prev_was_nullable = prev_was_nullable || is_optional;
1379            }
1380            PathSegment::Length => {
1381                let size_nav = if prev_was_nullable { "?" } else { "" };
1382                out.push_str(&format!("{size_nav}.size"));
1383                prev_was_nullable = false;
1384            }
1385        }
1386    }
1387    out
1388}
1389
1390/// Non-optional variant of `render_kotlin_android_with_optionals`.
1391///
1392/// Used by `render_accessor` (the path without per-field optionality tracking).
1393fn render_kotlin_android(segments: &[PathSegment], result_var: &str) -> String {
1394    let mut out = result_var.to_string();
1395    for seg in segments {
1396        match seg {
1397            PathSegment::Field(f) => {
1398                out.push('.');
1399                out.push_str(&kotlin_getter(f));
1400                // No () — property access.
1401            }
1402            PathSegment::ArrayField { name, index } => {
1403                out.push('.');
1404                out.push_str(&kotlin_getter(name));
1405                if *index == 0 {
1406                    out.push_str(".first()");
1407                } else {
1408                    out.push_str(&format!(".get({index})"));
1409                }
1410            }
1411            PathSegment::MapAccess { field, key } => {
1412                out.push('.');
1413                out.push_str(&kotlin_getter(field));
1414                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1415                if is_numeric {
1416                    out.push_str(&format!(".get({key})"));
1417                } else {
1418                    out.push_str(&format!(".get(\"{key}\")"));
1419                }
1420            }
1421            PathSegment::Length => {
1422                out.push_str(".size");
1423            }
1424        }
1425    }
1426    out
1427}
1428
1429/// Rust accessor with Option unwrapping for intermediate fields.
1430///
1431/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
1432/// is appended after the field access to unwrap the `Option<T>`.
1433/// When a path is in `method_calls`, `()` is appended to make it a method call.
1434fn render_rust_with_optionals(
1435    segments: &[PathSegment],
1436    result_var: &str,
1437    optional_fields: &HashSet<String>,
1438    method_calls: &HashSet<String>,
1439) -> String {
1440    let mut out = result_var.to_string();
1441    let mut path_so_far = String::new();
1442    for (i, seg) in segments.iter().enumerate() {
1443        let is_leaf = i == segments.len() - 1;
1444        match seg {
1445            PathSegment::Field(f) => {
1446                if !path_so_far.is_empty() {
1447                    path_so_far.push('.');
1448                }
1449                path_so_far.push_str(f);
1450                out.push('.');
1451                out.push_str(&f.to_snake_case());
1452                let is_method = method_calls.contains(&path_so_far);
1453                if is_method {
1454                    out.push_str("()");
1455                    if !is_leaf && optional_fields.contains(&path_so_far) {
1456                        out.push_str(".as_ref().unwrap()");
1457                    }
1458                } else if !is_leaf && optional_fields.contains(&path_so_far) {
1459                    out.push_str(".as_ref().unwrap()");
1460                }
1461            }
1462            PathSegment::ArrayField { name, index } => {
1463                if !path_so_far.is_empty() {
1464                    path_so_far.push('.');
1465                }
1466                path_so_far.push_str(name);
1467                out.push('.');
1468                out.push_str(&name.to_snake_case());
1469                // Option<Vec<T>>: must unwrap the Option before indexing.
1470                // Check both "name" (bare) and "name[0]" (indexed) forms since the
1471                // optional_fields registry may use either convention.
1472                let path_with_idx = format!("{path_so_far}[0]");
1473                let is_opt = optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1474                if is_opt {
1475                    out.push_str(&format!(".as_ref().unwrap()[{index}]"));
1476                } else {
1477                    out.push_str(&format!("[{index}]"));
1478                }
1479                // Record the normalised "[0]" suffix in path_so_far so that deeper
1480                // optional-field keys which include explicit indices (e.g.
1481                // "choices[0].message.tool_calls") continue to match when we check
1482                // subsequent segments.
1483                path_so_far.push_str("[0]");
1484            }
1485            PathSegment::MapAccess { field, key } => {
1486                if !path_so_far.is_empty() {
1487                    path_so_far.push('.');
1488                }
1489                path_so_far.push_str(field);
1490                out.push('.');
1491                out.push_str(&field.to_snake_case());
1492                if key.chars().all(|c| c.is_ascii_digit()) {
1493                    // Check optional both with and without the numeric index suffix.
1494                    let path_with_idx = format!("{path_so_far}[0]");
1495                    let is_opt =
1496                        optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1497                    if is_opt {
1498                        out.push_str(&format!(".as_ref().unwrap()[{key}]"));
1499                    } else {
1500                        out.push_str(&format!("[{key}]"));
1501                    }
1502                    path_so_far.push_str("[0]");
1503                } else {
1504                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
1505                }
1506            }
1507            PathSegment::Length => {
1508                out.push_str(".len()");
1509            }
1510        }
1511    }
1512    out
1513}
1514
1515/// Zig accessor that unwraps optional fields with `.?`.
1516///
1517/// Zig does not allow field access, indexing, or comparisons through `?T`;
1518/// the value must be unwrapped first. Each segment whose path appears in the
1519/// optional-field set is followed by `.?` so the resulting expression is a
1520/// concrete value usable in assertions.
1521///
1522/// Paths in `method_calls` represent tagged-union variant accessors (Rust
1523/// variant getters such as `FormatMetadata::excel()`). In Zig, tagged-union
1524/// variants are accessed via the same dot syntax as struct fields, so the
1525/// segment is emitted as `.{name}` *without* `.?` even if the path also
1526/// appears in `optional_fields`.
1527fn render_zig_with_optionals(
1528    segments: &[PathSegment],
1529    result_var: &str,
1530    optional_fields: &HashSet<String>,
1531    method_calls: &HashSet<String>,
1532) -> String {
1533    let mut out = result_var.to_string();
1534    let mut path_so_far = String::new();
1535    for seg in segments {
1536        match seg {
1537            PathSegment::Field(f) => {
1538                if !path_so_far.is_empty() {
1539                    path_so_far.push('.');
1540                }
1541                path_so_far.push_str(f);
1542                out.push('.');
1543                out.push_str(f);
1544                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1545                    out.push_str(".?");
1546                }
1547            }
1548            PathSegment::ArrayField { name, index } => {
1549                if !path_so_far.is_empty() {
1550                    path_so_far.push('.');
1551                }
1552                path_so_far.push_str(name);
1553                out.push('.');
1554                out.push_str(name);
1555                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1556                    out.push_str(".?");
1557                }
1558                out.push_str(&format!("[{index}]"));
1559            }
1560            PathSegment::MapAccess { field, key } => {
1561                if !path_so_far.is_empty() {
1562                    path_so_far.push('.');
1563                }
1564                path_so_far.push_str(field);
1565                out.push('.');
1566                out.push_str(field);
1567                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1568                    out.push_str(".?");
1569                }
1570                if key.chars().all(|c| c.is_ascii_digit()) {
1571                    out.push_str(&format!("[{key}]"));
1572                } else {
1573                    out.push_str(&format!(".get(\"{key}\")"));
1574                }
1575            }
1576            PathSegment::Length => {
1577                out.push_str(".len");
1578            }
1579        }
1580    }
1581    out
1582}
1583
1584fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
1585    let mut out = result_var.to_string();
1586    for seg in segments {
1587        match seg {
1588            PathSegment::Field(f) => {
1589                out.push('.');
1590                out.push_str(&f.to_pascal_case());
1591            }
1592            PathSegment::ArrayField { name, index } => {
1593                out.push('.');
1594                out.push_str(&name.to_pascal_case());
1595                out.push_str(&format!("[{index}]"));
1596            }
1597            PathSegment::MapAccess { field, key } => {
1598                out.push('.');
1599                out.push_str(&field.to_pascal_case());
1600                if key.chars().all(|c| c.is_ascii_digit()) {
1601                    out.push_str(&format!("[{key}]"));
1602                } else {
1603                    out.push_str(&format!("[\"{key}\"]"));
1604                }
1605            }
1606            PathSegment::Length => {
1607                out.push_str(".Count");
1608            }
1609        }
1610    }
1611    out
1612}
1613
1614fn render_csharp_with_optionals(
1615    segments: &[PathSegment],
1616    result_var: &str,
1617    optional_fields: &HashSet<String>,
1618) -> String {
1619    let mut out = result_var.to_string();
1620    let mut path_so_far = String::new();
1621    for (i, seg) in segments.iter().enumerate() {
1622        let is_leaf = i == segments.len() - 1;
1623        match seg {
1624            PathSegment::Field(f) => {
1625                if !path_so_far.is_empty() {
1626                    path_so_far.push('.');
1627                }
1628                path_so_far.push_str(f);
1629                out.push('.');
1630                out.push_str(&f.to_pascal_case());
1631                if !is_leaf && optional_fields.contains(&path_so_far) {
1632                    out.push('!');
1633                }
1634            }
1635            PathSegment::ArrayField { name, index } => {
1636                if !path_so_far.is_empty() {
1637                    path_so_far.push('.');
1638                }
1639                path_so_far.push_str(name);
1640                out.push('.');
1641                out.push_str(&name.to_pascal_case());
1642                out.push_str(&format!("[{index}]"));
1643            }
1644            PathSegment::MapAccess { field, key } => {
1645                if !path_so_far.is_empty() {
1646                    path_so_far.push('.');
1647                }
1648                path_so_far.push_str(field);
1649                out.push('.');
1650                out.push_str(&field.to_pascal_case());
1651                if key.chars().all(|c| c.is_ascii_digit()) {
1652                    out.push_str(&format!("[{key}]"));
1653                } else {
1654                    out.push_str(&format!("[\"{key}\"]"));
1655                }
1656            }
1657            PathSegment::Length => {
1658                out.push_str(".Count");
1659            }
1660        }
1661    }
1662    out
1663}
1664
1665fn render_php(segments: &[PathSegment], result_var: &str) -> String {
1666    let mut out = result_var.to_string();
1667    for seg in segments {
1668        match seg {
1669            PathSegment::Field(f) => {
1670                out.push_str("->");
1671                // PHP properties are camelCase (per #[php(prop, name = "...")]),
1672                // so convert snake_case field names to camelCase.
1673                out.push_str(&f.to_lower_camel_case());
1674            }
1675            PathSegment::ArrayField { name, index } => {
1676                out.push_str("->");
1677                out.push_str(&name.to_lower_camel_case());
1678                out.push_str(&format!("[{index}]"));
1679            }
1680            PathSegment::MapAccess { field, key } => {
1681                out.push_str("->");
1682                out.push_str(&field.to_lower_camel_case());
1683                out.push_str(&format!("[\"{key}\"]"));
1684            }
1685            PathSegment::Length => {
1686                let current = std::mem::take(&mut out);
1687                out = format!("count({current})");
1688            }
1689        }
1690    }
1691    out
1692}
1693
1694/// PHP accessor that distinguishes between scalar fields (property access: `->camelCase`)
1695/// and non-scalar fields (getter-method access: `->getCamelCase()`).
1696///
1697/// ext-php-rs 0.15.x exposes scalar fields via `#[php(prop)]` as PHP properties, but
1698/// non-scalar fields (Named structs, `Vec<Named>`, `Map`, etc.) require a `#[php(getter)]`
1699/// method because `get_method_props` is `todo!()` in ext-php-rs-derive 0.11.7.
1700/// The generated getter method name is `get{CamelCase}` (stripping the `get_` prefix and
1701/// converting the camelCase remainder to a PHP property name), so e2e assertions must call
1702/// `->getCamelCase()` for those fields.
1703///
1704/// `getter_map` carries the per-`(owner_type, field_name)` classification along with the
1705/// chain-resolution metadata required to walk multi-segment paths through the IR's nested
1706/// type graph. Each path segment is classified using the *current* owner type, then the
1707/// owner cursor advances to the field's referenced `Named` type (if any) for the next
1708/// segment. When `root_type` is unset the renderer falls back to the legacy bare-name
1709/// union, which is unsafe but preserves backwards compatibility for callers that have
1710/// not wired type resolution.
1711fn render_php_with_getters(segments: &[PathSegment], result_var: &str, getter_map: &PhpGetterMap) -> String {
1712    let mut out = result_var.to_string();
1713    let mut current_type: Option<String> = getter_map.root_type.clone();
1714    for seg in segments {
1715        match seg {
1716            PathSegment::Field(f) => {
1717                let camel = f.to_lower_camel_case();
1718                if getter_map.needs_getter(current_type.as_deref(), f.as_str()) {
1719                    // Non-scalar field: ext-php-rs emits a `get{CamelCase}()` method.
1720                    // The `get_` prefix is stripped by ext-php-rs when it derives the
1721                    // PHP property name, but the Rust method ident is `get_{camelCase}`,
1722                    // so the PHP call is `->get{CamelCase}()`.
1723                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1724                    out.push_str("->");
1725                    out.push_str(&getter);
1726                    out.push_str("()");
1727                } else {
1728                    out.push_str("->");
1729                    out.push_str(&camel);
1730                }
1731                current_type = getter_map.advance(current_type.as_deref(), f.as_str());
1732            }
1733            PathSegment::ArrayField { name, index } => {
1734                let camel = name.to_lower_camel_case();
1735                if getter_map.needs_getter(current_type.as_deref(), name.as_str()) {
1736                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1737                    out.push_str("->");
1738                    out.push_str(&getter);
1739                    out.push_str("()");
1740                } else {
1741                    out.push_str("->");
1742                    out.push_str(&camel);
1743                }
1744                out.push_str(&format!("[{index}]"));
1745                current_type = getter_map.advance(current_type.as_deref(), name.as_str());
1746            }
1747            PathSegment::MapAccess { field, key } => {
1748                let camel = field.to_lower_camel_case();
1749                if getter_map.needs_getter(current_type.as_deref(), field.as_str()) {
1750                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1751                    out.push_str("->");
1752                    out.push_str(&getter);
1753                    out.push_str("()");
1754                } else {
1755                    out.push_str("->");
1756                    out.push_str(&camel);
1757                }
1758                out.push_str(&format!("[\"{key}\"]"));
1759                current_type = getter_map.advance(current_type.as_deref(), field.as_str());
1760            }
1761            PathSegment::Length => {
1762                let current = std::mem::take(&mut out);
1763                out = format!("count({current})");
1764            }
1765        }
1766    }
1767    out
1768}
1769
1770fn render_r(segments: &[PathSegment], result_var: &str) -> String {
1771    let mut out = result_var.to_string();
1772    for seg in segments {
1773        match seg {
1774            PathSegment::Field(f) => {
1775                out.push('$');
1776                out.push_str(f);
1777            }
1778            PathSegment::ArrayField { name, index } => {
1779                out.push('$');
1780                out.push_str(name);
1781                // R uses 1-based indexing.
1782                out.push_str(&format!("[[{}]]", index + 1));
1783            }
1784            PathSegment::MapAccess { field, key } => {
1785                out.push('$');
1786                out.push_str(field);
1787                out.push_str(&format!("[[\"{key}\"]]"));
1788            }
1789            PathSegment::Length => {
1790                let current = std::mem::take(&mut out);
1791                out = format!("length({current})");
1792            }
1793        }
1794    }
1795    out
1796}
1797
1798fn render_c(segments: &[PathSegment], result_var: &str) -> String {
1799    let mut parts = Vec::new();
1800    let mut trailing_length = false;
1801    for seg in segments {
1802        match seg {
1803            PathSegment::Field(f) => parts.push(f.to_snake_case()),
1804            PathSegment::ArrayField { name, .. } => parts.push(name.to_snake_case()),
1805            PathSegment::MapAccess { field, key } => {
1806                parts.push(field.to_snake_case());
1807                parts.push(key.clone());
1808            }
1809            PathSegment::Length => {
1810                trailing_length = true;
1811            }
1812        }
1813    }
1814    let suffix = parts.join("_");
1815    if trailing_length {
1816        format!("result_{suffix}_count({result_var})")
1817    } else {
1818        format!("result_{suffix}({result_var})")
1819    }
1820}
1821
1822/// Dart accessor using camelCase field names (FRB v2 convention).
1823///
1824/// FRB v2 generates Dart property getters with camelCase names for every
1825/// snake_case Rust field, so `snake_case_field` becomes `snakeCaseField`.
1826/// Array fields index with `[N]`; map fields use `["key"]` or `[N]` notation.
1827/// Length/count segments use `.length` (Dart `List.length`).
1828fn render_dart(segments: &[PathSegment], result_var: &str) -> String {
1829    let mut out = result_var.to_string();
1830    for seg in segments {
1831        match seg {
1832            PathSegment::Field(f) => {
1833                out.push('.');
1834                out.push_str(&f.to_lower_camel_case());
1835            }
1836            PathSegment::ArrayField { name, index } => {
1837                out.push('.');
1838                out.push_str(&name.to_lower_camel_case());
1839                out.push_str(&format!("[{index}]"));
1840            }
1841            PathSegment::MapAccess { field, key } => {
1842                out.push('.');
1843                out.push_str(&field.to_lower_camel_case());
1844                if key.chars().all(|c| c.is_ascii_digit()) {
1845                    out.push_str(&format!("[{key}]"));
1846                } else {
1847                    out.push_str(&format!("[\"{key}\"]"));
1848                }
1849            }
1850            PathSegment::Length => {
1851                out.push_str(".length");
1852            }
1853        }
1854    }
1855    out
1856}
1857
1858/// Dart accessor with optional-safe navigation using `?.` (FRB v2 convention).
1859///
1860/// When an intermediate field is in `optional_fields`, the next segment uses
1861/// `?.` safe-call navigation instead of `.` to avoid a null-dereference on
1862/// a nullable Dart type.  Field names are camelCase (FRB v2 generation rule).
1863fn render_dart_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1864    let mut out = result_var.to_string();
1865    let mut path_so_far = String::new();
1866    let mut prev_was_nullable = false;
1867    for seg in segments {
1868        let nav = if prev_was_nullable { "?." } else { "." };
1869        match seg {
1870            PathSegment::Field(f) => {
1871                if !path_so_far.is_empty() {
1872                    path_so_far.push('.');
1873                }
1874                path_so_far.push_str(f);
1875                let is_optional = optional_fields.contains(&path_so_far);
1876                out.push_str(nav);
1877                out.push_str(&f.to_lower_camel_case());
1878                prev_was_nullable = is_optional;
1879            }
1880            PathSegment::ArrayField { name, index } => {
1881                if !path_so_far.is_empty() {
1882                    path_so_far.push('.');
1883                }
1884                path_so_far.push_str(name);
1885                let is_optional = optional_fields.contains(&path_so_far);
1886                out.push_str(nav);
1887                out.push_str(&name.to_lower_camel_case());
1888                // FRB models `Option<Vec<T>>` as `List<T>?` — only force-unwrap when the field
1889                // is registered as optional. Adding `!` to a non-nullable receiver is a Dart
1890                // compile-time error ("unnecessary non-null assertion").
1891                if is_optional {
1892                    out.push('!');
1893                }
1894                out.push_str(&format!("[{index}]"));
1895                prev_was_nullable = false;
1896            }
1897            PathSegment::MapAccess { field, key } => {
1898                if !path_so_far.is_empty() {
1899                    path_so_far.push('.');
1900                }
1901                path_so_far.push_str(field);
1902                let is_optional = optional_fields.contains(&path_so_far);
1903                out.push_str(nav);
1904                out.push_str(&field.to_lower_camel_case());
1905                if key.chars().all(|c| c.is_ascii_digit()) {
1906                    out.push_str(&format!("[{key}]"));
1907                } else {
1908                    out.push_str(&format!("[\"{key}\"]"));
1909                }
1910                prev_was_nullable = is_optional;
1911            }
1912            PathSegment::Length => {
1913                // Use `?.length` when the receiver is optional — emitting `.length` against
1914                // a `List<T>?` is a Dart sound-null-safety error.
1915                out.push_str(nav);
1916                out.push_str("length");
1917                prev_was_nullable = false;
1918            }
1919        }
1920    }
1921    out
1922}
1923
1924#[cfg(test)]
1925mod tests {
1926    use super::*;
1927
1928    fn make_resolver() -> FieldResolver {
1929        let mut fields = HashMap::new();
1930        fields.insert("title".to_string(), "metadata.document.title".to_string());
1931        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1932        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
1933        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
1934        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
1935        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
1936        let mut optional = HashSet::new();
1937        optional.insert("metadata.document.title".to_string());
1938        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1939    }
1940
1941    fn make_resolver_with_doc_optional() -> FieldResolver {
1942        let mut fields = HashMap::new();
1943        fields.insert("title".to_string(), "metadata.document.title".to_string());
1944        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1945        let mut optional = HashSet::new();
1946        optional.insert("document".to_string());
1947        optional.insert("metadata.document.title".to_string());
1948        optional.insert("metadata.document".to_string());
1949        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1950    }
1951
1952    #[test]
1953    fn test_resolve_alias() {
1954        let r = make_resolver();
1955        assert_eq!(r.resolve("title"), "metadata.document.title");
1956    }
1957
1958    #[test]
1959    fn test_resolve_passthrough() {
1960        let r = make_resolver();
1961        assert_eq!(r.resolve("content"), "content");
1962    }
1963
1964    #[test]
1965    fn test_is_optional() {
1966        let r = make_resolver();
1967        assert!(r.is_optional("metadata.document.title"));
1968        assert!(!r.is_optional("content"));
1969    }
1970
1971    #[test]
1972    fn test_accessor_rust_struct() {
1973        let r = make_resolver();
1974        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
1975    }
1976
1977    #[test]
1978    fn test_accessor_rust_map() {
1979        let r = make_resolver();
1980        assert_eq!(
1981            r.accessor("tags", "rust", "result"),
1982            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
1983        );
1984    }
1985
1986    #[test]
1987    fn test_accessor_python() {
1988        let r = make_resolver();
1989        assert_eq!(
1990            r.accessor("title", "python", "result"),
1991            "result.metadata.document.title"
1992        );
1993    }
1994
1995    #[test]
1996    fn test_accessor_go() {
1997        let r = make_resolver();
1998        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
1999    }
2000
2001    #[test]
2002    fn test_accessor_go_initialism_fields() {
2003        let mut fields = std::collections::HashMap::new();
2004        fields.insert("content".to_string(), "html".to_string());
2005        fields.insert("link_url".to_string(), "links.url".to_string());
2006        let r = FieldResolver::new(
2007            &fields,
2008            &HashSet::new(),
2009            &HashSet::new(),
2010            &HashSet::new(),
2011            &HashSet::new(),
2012        );
2013        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
2014        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
2015        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
2016        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
2017        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
2018        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
2019        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
2020        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
2021    }
2022
2023    #[test]
2024    fn test_accessor_typescript() {
2025        let r = make_resolver();
2026        assert_eq!(
2027            r.accessor("title", "typescript", "result"),
2028            "result.metadata.document.title"
2029        );
2030    }
2031
2032    #[test]
2033    fn test_accessor_typescript_snake_to_camel() {
2034        let r = make_resolver();
2035        assert_eq!(
2036            r.accessor("og", "typescript", "result"),
2037            "result.metadata.document.openGraph"
2038        );
2039        assert_eq!(
2040            r.accessor("twitter", "typescript", "result"),
2041            "result.metadata.document.twitterCard"
2042        );
2043        assert_eq!(
2044            r.accessor("canonical", "typescript", "result"),
2045            "result.metadata.document.canonicalUrl"
2046        );
2047    }
2048
2049    #[test]
2050    fn test_accessor_typescript_map_snake_to_camel() {
2051        let r = make_resolver();
2052        assert_eq!(
2053            r.accessor("og_tag", "typescript", "result"),
2054            "result.metadata.openGraphTags[\"og_title\"]"
2055        );
2056    }
2057
2058    #[test]
2059    fn test_accessor_typescript_numeric_index_is_unquoted() {
2060        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
2061        // must emit numeric bracket access (`[0]`) not string-keyed access
2062        // (`["0"]`), which would return undefined on arrays.
2063        let mut fields = HashMap::new();
2064        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
2065        let r = FieldResolver::new(
2066            &fields,
2067            &HashSet::new(),
2068            &HashSet::new(),
2069            &HashSet::new(),
2070            &HashSet::new(),
2071        );
2072        assert_eq!(
2073            r.accessor("first_score", "typescript", "result"),
2074            "result.results[0].relevanceScore"
2075        );
2076    }
2077
2078    #[test]
2079    fn test_accessor_node_alias() {
2080        let r = make_resolver();
2081        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
2082    }
2083
2084    #[test]
2085    fn test_accessor_wasm_camel_case() {
2086        let r = make_resolver();
2087        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
2088        assert_eq!(
2089            r.accessor("twitter", "wasm", "result"),
2090            "result.metadata.document.twitterCard"
2091        );
2092        assert_eq!(
2093            r.accessor("canonical", "wasm", "result"),
2094            "result.metadata.document.canonicalUrl"
2095        );
2096    }
2097
2098    #[test]
2099    fn test_accessor_wasm_map_access() {
2100        let r = make_resolver();
2101        assert_eq!(
2102            r.accessor("og_tag", "wasm", "result"),
2103            "result.metadata.openGraphTags.get(\"og_title\")"
2104        );
2105    }
2106
2107    #[test]
2108    fn test_accessor_java() {
2109        let r = make_resolver();
2110        assert_eq!(
2111            r.accessor("title", "java", "result"),
2112            "result.metadata().document().title()"
2113        );
2114    }
2115
2116    #[test]
2117    fn test_accessor_kotlin_uses_kotlin_collection_idioms() {
2118        let mut fields = HashMap::new();
2119        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2120        fields.insert("node_count".to_string(), "nodes.length".to_string());
2121        let mut arrays = HashSet::new();
2122        arrays.insert("nodes".to_string());
2123        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &arrays, &HashSet::new());
2124        assert_eq!(
2125            r.accessor("first_node_name", "kotlin", "result"),
2126            "result.nodes().first().name()"
2127        );
2128        assert_eq!(r.accessor("node_count", "kotlin", "result"), "result.nodes().size");
2129    }
2130
2131    #[test]
2132    fn test_accessor_kotlin_uses_safe_calls_for_optional_prefixes() {
2133        let r = make_resolver_with_doc_optional();
2134        assert_eq!(
2135            r.accessor("title", "kotlin", "result"),
2136            "result.metadata().document()?.title()"
2137        );
2138    }
2139
2140    #[test]
2141    fn test_accessor_kotlin_uses_safe_calls_for_optional_arrays_and_maps() {
2142        let mut fields = HashMap::new();
2143        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2144        fields.insert("tag".to_string(), "tags[name]".to_string());
2145        let mut optional = HashSet::new();
2146        optional.insert("nodes".to_string());
2147        optional.insert("tags".to_string());
2148        let mut arrays = HashSet::new();
2149        arrays.insert("nodes".to_string());
2150        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2151        assert_eq!(
2152            r.accessor("first_node_name", "kotlin", "result"),
2153            "result.nodes()?.first()?.name()"
2154        );
2155        assert_eq!(r.accessor("tag", "kotlin", "result"), "result.tags()?.get(\"name\")");
2156    }
2157
2158    /// Regression: optional-field keys with explicit `[0]` indices (e.g.
2159    /// `"choices[0].message.tool_calls"`) were not matched by
2160    /// `render_kotlin_with_optionals` because `path_so_far` omitted the `[0]`
2161    /// suffix after traversing an ArrayField segment. Fix: append `"[0]"` to
2162    /// `path_so_far` after each ArrayField, mirroring the Rust renderer.
2163    #[test]
2164    fn test_accessor_kotlin_optional_field_after_indexed_array() {
2165        // "choices[0].message.tool_calls" is optional; the path is accessed as
2166        // choices[0].message.tool_calls[0].function.name.
2167        let mut fields = HashMap::new();
2168        fields.insert(
2169            "tool_call_name".to_string(),
2170            "choices[0].message.tool_calls[0].function.name".to_string(),
2171        );
2172        let mut optional = HashSet::new();
2173        optional.insert("choices[0].message.tool_calls".to_string());
2174        let mut arrays = HashSet::new();
2175        arrays.insert("choices".to_string());
2176        arrays.insert("choices[0].message.tool_calls".to_string());
2177        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2178        let expr = r.accessor("tool_call_name", "kotlin", "result");
2179        // toolCalls() is optional so it must use `?.` before `.first()`.
2180        assert!(
2181            expr.contains("toolCalls()?.first()"),
2182            "expected toolCalls()?.first() for optional list, got: {expr}"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_accessor_csharp() {
2188        let r = make_resolver();
2189        assert_eq!(
2190            r.accessor("title", "csharp", "result"),
2191            "result.Metadata.Document.Title"
2192        );
2193    }
2194
2195    #[test]
2196    fn test_accessor_php() {
2197        let r = make_resolver();
2198        assert_eq!(
2199            r.accessor("title", "php", "$result"),
2200            "$result->metadata->document->title"
2201        );
2202    }
2203
2204    #[test]
2205    fn test_accessor_r() {
2206        let r = make_resolver();
2207        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
2208    }
2209
2210    #[test]
2211    fn test_accessor_c() {
2212        let r = make_resolver();
2213        assert_eq!(
2214            r.accessor("title", "c", "result"),
2215            "result_metadata_document_title(result)"
2216        );
2217    }
2218
2219    #[test]
2220    fn test_rust_unwrap_binding() {
2221        let r = make_resolver();
2222        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
2223        assert_eq!(var, "metadata_document_title");
2224        assert!(binding.contains("as_ref().map(|v| v.to_string()).unwrap_or_default()"));
2225    }
2226
2227    #[test]
2228    fn test_rust_unwrap_binding_non_optional() {
2229        let r = make_resolver();
2230        assert!(r.rust_unwrap_binding("content", "result").is_none());
2231    }
2232
2233    #[test]
2234    fn test_rust_unwrap_binding_collapses_double_underscore() {
2235        // When an alias resolves to a path with `[]` (e.g. `json_ld.name` →
2236        // `json_ld[].name`), the naive replace previously yielded `json_ld__name`,
2237        // which trips Rust's non_snake_case lint under -D warnings. The local
2238        // binding name must collapse consecutive underscores into one.
2239        let mut aliases = HashMap::new();
2240        aliases.insert("json_ld.name".to_string(), "json_ld[].name".to_string());
2241        let mut optional = HashSet::new();
2242        optional.insert("json_ld[].name".to_string());
2243        let mut array = HashSet::new();
2244        array.insert("json_ld".to_string());
2245        let result_fields = HashSet::new();
2246        let method_calls = HashSet::new();
2247        let r = FieldResolver::new(&aliases, &optional, &result_fields, &array, &method_calls);
2248        let (_binding, var) = r.rust_unwrap_binding("json_ld.name", "result").unwrap();
2249        assert_eq!(var, "json_ld_name");
2250    }
2251
2252    #[test]
2253    fn test_direct_field_no_alias() {
2254        let r = make_resolver();
2255        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2256        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
2257    }
2258
2259    #[test]
2260    fn test_accessor_rust_with_optionals() {
2261        let r = make_resolver_with_doc_optional();
2262        assert_eq!(
2263            r.accessor("title", "rust", "result"),
2264            "result.metadata.document.as_ref().unwrap().title"
2265        );
2266    }
2267
2268    #[test]
2269    fn test_accessor_csharp_with_optionals() {
2270        let r = make_resolver_with_doc_optional();
2271        assert_eq!(
2272            r.accessor("title", "csharp", "result"),
2273            "result.Metadata.Document!.Title"
2274        );
2275    }
2276
2277    #[test]
2278    fn test_accessor_rust_non_optional_field() {
2279        let r = make_resolver();
2280        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2281    }
2282
2283    #[test]
2284    fn test_accessor_csharp_non_optional_field() {
2285        let r = make_resolver();
2286        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
2287    }
2288
2289    #[test]
2290    fn test_accessor_rust_method_call() {
2291        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
2292        let mut fields = HashMap::new();
2293        fields.insert(
2294            "excel_sheet_count".to_string(),
2295            "metadata.format.excel.sheet_count".to_string(),
2296        );
2297        let mut optional = HashSet::new();
2298        optional.insert("metadata.format".to_string());
2299        optional.insert("metadata.format.excel".to_string());
2300        let mut method_calls = HashSet::new();
2301        method_calls.insert("metadata.format.excel".to_string());
2302        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
2303        assert_eq!(
2304            r.accessor("excel_sheet_count", "rust", "result"),
2305            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
2306        );
2307    }
2308
2309    // ---------------------------------------------------------------------------
2310    // PHP getter-method tests (ext-php-rs 0.15.x `#[php(getter)]` vs `#[php(prop)]`)
2311    // ---------------------------------------------------------------------------
2312
2313    fn make_php_getter_resolver() -> FieldResolver {
2314        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2315        getters.insert(
2316            "Root".to_string(),
2317            ["metadata".to_string(), "links".to_string()].into_iter().collect(),
2318        );
2319        let map = PhpGetterMap {
2320            getters,
2321            field_types: HashMap::new(),
2322            root_type: Some("Root".to_string()),
2323            all_fields: HashMap::new(),
2324        };
2325        FieldResolver::new_with_php_getters(
2326            &HashMap::new(),
2327            &HashSet::new(),
2328            &HashSet::new(),
2329            &HashSet::new(),
2330            &HashSet::new(),
2331            &HashMap::new(),
2332            map,
2333        )
2334    }
2335
2336    #[test]
2337    fn render_php_uses_getter_method_for_non_scalar_field() {
2338        let r = make_php_getter_resolver();
2339        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->getMetadata()");
2340    }
2341
2342    #[test]
2343    fn render_php_uses_property_for_scalar_field() {
2344        let r = make_php_getter_resolver();
2345        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2346    }
2347
2348    #[test]
2349    fn render_php_nested_non_scalar_uses_getter_then_property() {
2350        let mut fields = HashMap::new();
2351        fields.insert("title".to_string(), "metadata.title".to_string());
2352        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2353        getters.insert("Root".to_string(), ["metadata".to_string()].into_iter().collect());
2354        // No entry for Metadata.title → scalar by default.
2355        getters.insert("Metadata".to_string(), HashSet::new());
2356        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2357        field_types.insert(
2358            "Root".to_string(),
2359            [("metadata".to_string(), "Metadata".to_string())].into_iter().collect(),
2360        );
2361        let map = PhpGetterMap {
2362            getters,
2363            field_types,
2364            root_type: Some("Root".to_string()),
2365            all_fields: HashMap::new(),
2366        };
2367        let r = FieldResolver::new_with_php_getters(
2368            &fields,
2369            &HashSet::new(),
2370            &HashSet::new(),
2371            &HashSet::new(),
2372            &HashSet::new(),
2373            &HashMap::new(),
2374            map,
2375        );
2376        // `metadata` → `->getMetadata()`, then `title` (scalar on returned object) → `->title`
2377        assert_eq!(r.accessor("title", "php", "$result"), "$result->getMetadata()->title");
2378    }
2379
2380    #[test]
2381    fn render_php_array_field_uses_getter_when_non_scalar() {
2382        let mut fields = HashMap::new();
2383        fields.insert("first_link".to_string(), "links[0]".to_string());
2384        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2385        getters.insert("Root".to_string(), ["links".to_string()].into_iter().collect());
2386        let map = PhpGetterMap {
2387            getters,
2388            field_types: HashMap::new(),
2389            root_type: Some("Root".to_string()),
2390            all_fields: HashMap::new(),
2391        };
2392        let r = FieldResolver::new_with_php_getters(
2393            &fields,
2394            &HashSet::new(),
2395            &HashSet::new(),
2396            &HashSet::new(),
2397            &HashSet::new(),
2398            &HashMap::new(),
2399            map,
2400        );
2401        assert_eq!(r.accessor("first_link", "php", "$result"), "$result->getLinks()[0]");
2402    }
2403
2404    #[test]
2405    fn render_php_falls_back_to_property_when_getter_fields_empty() {
2406        // With empty php_getter_map the resolver uses the plain `render_php` path,
2407        // which emits `->camelCase` for every field regardless of scalar-ness.
2408        let r = FieldResolver::new(
2409            &HashMap::new(),
2410            &HashSet::new(),
2411            &HashSet::new(),
2412            &HashSet::new(),
2413            &HashSet::new(),
2414        );
2415        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2416        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->metadata");
2417    }
2418
2419    // Regression: bare-name HashSet classification produced false getters when two
2420    // types shared a field name with different scalarness (kreuzcrawl `content`
2421    // collision between CrawlConfig.content: ContentConfig and MarkdownResult.content: String).
2422    #[test]
2423    fn render_php_with_getters_distinguishes_same_field_name_on_different_types() {
2424        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2425        // A.content is non-scalar.
2426        getters.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2427        // B.content is scalar — explicit empty set.
2428        getters.insert("B".to_string(), HashSet::new());
2429        // Both A and B declare a "content" field — needed so the per-type
2430        // classification is consulted (not fallback bare-name union).
2431        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2432        all_fields.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2433        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2434        let map_a = PhpGetterMap {
2435            getters: getters.clone(),
2436            field_types: HashMap::new(),
2437            root_type: Some("A".to_string()),
2438            all_fields: all_fields.clone(),
2439        };
2440        let map_b = PhpGetterMap {
2441            getters,
2442            field_types: HashMap::new(),
2443            root_type: Some("B".to_string()),
2444            all_fields,
2445        };
2446        let r_a = FieldResolver::new_with_php_getters(
2447            &HashMap::new(),
2448            &HashSet::new(),
2449            &HashSet::new(),
2450            &HashSet::new(),
2451            &HashSet::new(),
2452            &HashMap::new(),
2453            map_a,
2454        );
2455        let r_b = FieldResolver::new_with_php_getters(
2456            &HashMap::new(),
2457            &HashSet::new(),
2458            &HashSet::new(),
2459            &HashSet::new(),
2460            &HashSet::new(),
2461            &HashMap::new(),
2462            map_b,
2463        );
2464        assert_eq!(r_a.accessor("content", "php", "$a"), "$a->getContent()");
2465        assert_eq!(r_b.accessor("content", "php", "$b"), "$b->content");
2466    }
2467
2468    // Regression: the chain renderer must advance current_type through the IR's
2469    // nested-type graph so a scalar field on a nested type is not falsely
2470    // classified as needing a getter because some other type uses the same name.
2471    #[test]
2472    fn render_php_with_getters_chains_through_correct_type() {
2473        let mut fields = HashMap::new();
2474        fields.insert("nested_content".to_string(), "inner.content".to_string());
2475        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2476        // Outer.inner is non-scalar (struct B).
2477        getters.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2478        // B.content is scalar.
2479        getters.insert("B".to_string(), HashSet::new());
2480        // Decoy: another type with non-scalar `content` field — used to verify
2481        // the legacy bare-name union would have produced the wrong answer.
2482        getters.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2483        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2484        field_types.insert(
2485            "Outer".to_string(),
2486            [("inner".to_string(), "B".to_string())].into_iter().collect(),
2487        );
2488        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2489        all_fields.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2490        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2491        all_fields.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2492        let map = PhpGetterMap {
2493            getters,
2494            field_types,
2495            root_type: Some("Outer".to_string()),
2496            all_fields,
2497        };
2498        let r = FieldResolver::new_with_php_getters(
2499            &fields,
2500            &HashSet::new(),
2501            &HashSet::new(),
2502            &HashSet::new(),
2503            &HashSet::new(),
2504            &HashMap::new(),
2505            map,
2506        );
2507        assert_eq!(
2508            r.accessor("nested_content", "php", "$result"),
2509            "$result->getInner()->content"
2510        );
2511    }
2512}