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