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    let total = segments.len();
854    for (i, seg) in segments.iter().enumerate() {
855        let is_leaf = i == total - 1;
856        let property_syntax = map.is_first_class(current_type.as_deref());
857        match seg {
858            PathSegment::Field(f) => {
859                if !path_so_far.is_empty() {
860                    path_so_far.push('.');
861                }
862                path_so_far.push_str(f);
863                out.push('.');
864                out.push_str(f);
865                if !property_syntax {
866                    out.push_str("()");
867                }
868                if !is_leaf && optional_fields.contains(&path_so_far) {
869                    out.push('?');
870                }
871                current_type = map.advance(current_type.as_deref(), f);
872            }
873            PathSegment::ArrayField { name, index } => {
874                if !path_so_far.is_empty() {
875                    path_so_far.push('.');
876                }
877                path_so_far.push_str(name);
878                let is_optional = optional_fields.contains(&path_so_far);
879                out.push('.');
880                out.push_str(name);
881                let access = if property_syntax { "" } else { "()" };
882                if is_optional {
883                    out.push_str(&format!("{access}?[{index}]"));
884                } else {
885                    out.push_str(&format!("{access}[{index}]"));
886                }
887                path_so_far.push_str("[0]");
888                // Indexing into a Vec<Named> yields a Named element — advance current_type.
889                current_type = map.advance(current_type.as_deref(), name);
890            }
891            PathSegment::MapAccess { field, key } => {
892                if !path_so_far.is_empty() {
893                    path_so_far.push('.');
894                }
895                path_so_far.push_str(field);
896                out.push('.');
897                out.push_str(field);
898                let access = if property_syntax { "" } else { "()" };
899                if key.chars().all(|c| c.is_ascii_digit()) {
900                    out.push_str(&format!("{access}[{key}]"));
901                } else {
902                    out.push_str(&format!("{access}[\"{key}\"]"));
903                }
904                current_type = map.advance(current_type.as_deref(), field);
905            }
906            PathSegment::Length => {
907                out.push_str(".count");
908            }
909        }
910    }
911    out
912}
913
914fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
915    let mut out = result_var.to_string();
916    for seg in segments {
917        match seg {
918            PathSegment::Field(f) => {
919                out.push('.');
920                out.push_str(&f.to_snake_case());
921            }
922            PathSegment::ArrayField { name, index } => {
923                out.push('.');
924                out.push_str(&name.to_snake_case());
925                out.push_str(&format!("[{index}]"));
926            }
927            PathSegment::MapAccess { field, key } => {
928                out.push('.');
929                out.push_str(&field.to_snake_case());
930                if key.chars().all(|c| c.is_ascii_digit()) {
931                    out.push_str(&format!("[{key}]"));
932                } else {
933                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
934                }
935            }
936            PathSegment::Length => {
937                out.push_str(".len()");
938            }
939        }
940    }
941    out
942}
943
944fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
945    let mut out = result_var.to_string();
946    for seg in segments {
947        match seg {
948            PathSegment::Field(f) => {
949                out.push('.');
950                out.push_str(f);
951            }
952            PathSegment::ArrayField { name, index } => {
953                if language == "elixir" {
954                    let current = std::mem::take(&mut out);
955                    out = format!("Enum.at({current}.{name}, {index})");
956                } else {
957                    out.push('.');
958                    out.push_str(name);
959                    out.push_str(&format!("[{index}]"));
960                }
961            }
962            PathSegment::MapAccess { field, key } => {
963                let is_numeric = key.chars().all(|c| c.is_ascii_digit());
964                if is_numeric && language == "elixir" {
965                    let current = std::mem::take(&mut out);
966                    out = format!("Enum.at({current}.{field}, {key})");
967                } else {
968                    out.push('.');
969                    out.push_str(field);
970                    if is_numeric {
971                        let idx: usize = key.parse().unwrap_or(0);
972                        out.push_str(&format!("[{idx}]"));
973                    } else if language == "elixir" || language == "ruby" {
974                        // Ruby/Elixir hashes use `["key"]` bracket access (Ruby's Hash has
975                        // no `get` method; Elixir maps use bracket access too).
976                        out.push_str(&format!("[\"{key}\"]"));
977                    } else {
978                        out.push_str(&format!(".get(\"{key}\")"));
979                    }
980                }
981            }
982            PathSegment::Length => match language {
983                "ruby" => out.push_str(".length"),
984                "elixir" => {
985                    let current = std::mem::take(&mut out);
986                    out = format!("length({current})");
987                }
988                "gleam" => {
989                    let current = std::mem::take(&mut out);
990                    out = format!("list.length({current})");
991                }
992                _ => {
993                    let current = std::mem::take(&mut out);
994                    out = format!("len({current})");
995                }
996            },
997        }
998    }
999    out
1000}
1001
1002fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
1003    let mut out = result_var.to_string();
1004    for seg in segments {
1005        match seg {
1006            PathSegment::Field(f) => {
1007                out.push('.');
1008                out.push_str(&f.to_lower_camel_case());
1009            }
1010            PathSegment::ArrayField { name, index } => {
1011                out.push('.');
1012                out.push_str(&name.to_lower_camel_case());
1013                out.push_str(&format!("[{index}]"));
1014            }
1015            PathSegment::MapAccess { field, key } => {
1016                out.push('.');
1017                out.push_str(&field.to_lower_camel_case());
1018                // Numeric (digit-only) keys index into arrays as integers, not as
1019                // string-keyed object properties; emit `[0]` not `["0"]`.
1020                if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
1021                    out.push_str(&format!("[{key}]"));
1022                } else {
1023                    out.push_str(&format!("[\"{key}\"]"));
1024                }
1025            }
1026            PathSegment::Length => {
1027                out.push_str(".length");
1028            }
1029        }
1030    }
1031    out
1032}
1033
1034fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
1035    let mut out = result_var.to_string();
1036    for seg in segments {
1037        match seg {
1038            PathSegment::Field(f) => {
1039                out.push('.');
1040                out.push_str(&f.to_lower_camel_case());
1041            }
1042            PathSegment::ArrayField { name, index } => {
1043                out.push('.');
1044                out.push_str(&name.to_lower_camel_case());
1045                out.push_str(&format!("[{index}]"));
1046            }
1047            PathSegment::MapAccess { field, key } => {
1048                out.push('.');
1049                out.push_str(&field.to_lower_camel_case());
1050                out.push_str(&format!(".get(\"{key}\")"));
1051            }
1052            PathSegment::Length => {
1053                out.push_str(".length");
1054            }
1055        }
1056    }
1057    out
1058}
1059
1060fn render_go(segments: &[PathSegment], result_var: &str) -> String {
1061    let mut out = result_var.to_string();
1062    for seg in segments {
1063        match seg {
1064            PathSegment::Field(f) => {
1065                out.push('.');
1066                out.push_str(&to_go_name(f));
1067            }
1068            PathSegment::ArrayField { name, index } => {
1069                out.push('.');
1070                out.push_str(&to_go_name(name));
1071                out.push_str(&format!("[{index}]"));
1072            }
1073            PathSegment::MapAccess { field, key } => {
1074                out.push('.');
1075                out.push_str(&to_go_name(field));
1076                if key.chars().all(|c| c.is_ascii_digit()) {
1077                    out.push_str(&format!("[{key}]"));
1078                } else {
1079                    out.push_str(&format!("[\"{key}\"]"));
1080                }
1081            }
1082            PathSegment::Length => {
1083                let current = std::mem::take(&mut out);
1084                out = format!("len({current})");
1085            }
1086        }
1087    }
1088    out
1089}
1090
1091fn render_java(segments: &[PathSegment], result_var: &str) -> String {
1092    let mut out = result_var.to_string();
1093    for seg in segments {
1094        match seg {
1095            PathSegment::Field(f) => {
1096                out.push('.');
1097                out.push_str(&f.to_lower_camel_case());
1098                out.push_str("()");
1099            }
1100            PathSegment::ArrayField { name, index } => {
1101                out.push('.');
1102                out.push_str(&name.to_lower_camel_case());
1103                out.push_str(&format!("().get({index})"));
1104            }
1105            PathSegment::MapAccess { field, key } => {
1106                out.push('.');
1107                out.push_str(&field.to_lower_camel_case());
1108                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1109                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1110                if is_numeric {
1111                    out.push_str(&format!("().get({key})"));
1112                } else {
1113                    out.push_str(&format!("().get(\"{key}\")"));
1114                }
1115            }
1116            PathSegment::Length => {
1117                out.push_str(".size()");
1118            }
1119        }
1120    }
1121    out
1122}
1123
1124/// Wrap a Kotlin getter name in backticks when it collides with a Kotlin hard keyword.
1125///
1126/// Hard keywords cannot be used as identifiers without escaping, so `result.object()`
1127/// is a syntax error; `` result.`object`() `` is the legal form.
1128fn kotlin_getter(name: &str) -> String {
1129    let camel = name.to_lower_camel_case();
1130    match camel.as_str() {
1131        "as" | "break" | "class" | "continue" | "do" | "else" | "false" | "for" | "fun" | "if" | "in" | "interface"
1132        | "is" | "null" | "object" | "package" | "return" | "super" | "this" | "throw" | "true" | "try"
1133        | "typealias" | "typeof" | "val" | "var" | "when" | "while" => format!("`{camel}`"),
1134        _ => camel,
1135    }
1136}
1137
1138fn render_kotlin(segments: &[PathSegment], result_var: &str) -> String {
1139    let mut out = result_var.to_string();
1140    for seg in segments {
1141        match seg {
1142            PathSegment::Field(f) => {
1143                out.push('.');
1144                out.push_str(&kotlin_getter(f));
1145                out.push_str("()");
1146            }
1147            PathSegment::ArrayField { name, index } => {
1148                out.push('.');
1149                out.push_str(&kotlin_getter(name));
1150                if *index == 0 {
1151                    out.push_str("().first()");
1152                } else {
1153                    out.push_str(&format!("().get({index})"));
1154                }
1155            }
1156            PathSegment::MapAccess { field, key } => {
1157                out.push('.');
1158                out.push_str(&kotlin_getter(field));
1159                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1160                if is_numeric {
1161                    out.push_str(&format!("().get({key})"));
1162                } else {
1163                    out.push_str(&format!("().get(\"{key}\")"));
1164                }
1165            }
1166            PathSegment::Length => {
1167                out.push_str(".size");
1168            }
1169        }
1170    }
1171    out
1172}
1173
1174fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1175    let mut out = result_var.to_string();
1176    let mut path_so_far = String::new();
1177    for (i, seg) in segments.iter().enumerate() {
1178        let is_leaf = i == segments.len() - 1;
1179        match seg {
1180            PathSegment::Field(f) => {
1181                if !path_so_far.is_empty() {
1182                    path_so_far.push('.');
1183                }
1184                path_so_far.push_str(f);
1185                out.push('.');
1186                out.push_str(&f.to_lower_camel_case());
1187                out.push_str("()");
1188                let _ = is_leaf;
1189                let _ = optional_fields;
1190            }
1191            PathSegment::ArrayField { name, index } => {
1192                if !path_so_far.is_empty() {
1193                    path_so_far.push('.');
1194                }
1195                path_so_far.push_str(name);
1196                out.push('.');
1197                out.push_str(&name.to_lower_camel_case());
1198                out.push_str(&format!("().get({index})"));
1199            }
1200            PathSegment::MapAccess { field, key } => {
1201                if !path_so_far.is_empty() {
1202                    path_so_far.push('.');
1203                }
1204                path_so_far.push_str(field);
1205                out.push('.');
1206                out.push_str(&field.to_lower_camel_case());
1207                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1208                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1209                if is_numeric {
1210                    out.push_str(&format!("().get({key})"));
1211                } else {
1212                    out.push_str(&format!("().get(\"{key}\")"));
1213                }
1214            }
1215            PathSegment::Length => {
1216                out.push_str(".size()");
1217            }
1218        }
1219    }
1220    out
1221}
1222
1223/// Kotlin variant of `render_java_with_optionals` using Kotlin idioms.
1224///
1225/// When the previous field in the chain is optional (nullable), uses `?.`
1226/// safe-call navigation for the next segment so the Kotlin compiler is
1227/// satisfied by the nullable receiver.
1228///
1229/// Nullability is **sticky**: once a `?.` safe-call has been emitted for any
1230/// segment, all subsequent segments also use `?.` because they operate on a
1231/// nullable receiver. A non-optional field after a `?.` call still returns
1232/// `T?` (because the whole chain can be null if any prefix was null).
1233///
1234/// Example: for `toolCalls[0].function.name` where `toolCalls` is optional:
1235/// `result.toolCalls()?.first()?.function()?.name()` — even though `function`
1236/// and `name` are themselves non-optional, they follow a `?.` chain.
1237fn render_kotlin_with_optionals(
1238    segments: &[PathSegment],
1239    result_var: &str,
1240    optional_fields: &HashSet<String>,
1241) -> String {
1242    let mut out = result_var.to_string();
1243    let mut path_so_far = String::new();
1244    // Track whether the previous segment returned a nullable type. Starts
1245    // false because `result_var` is always non-null.
1246    //
1247    // This flag is sticky: once set to true it stays true for the rest of
1248    // the chain because a `?.` call returns `T?` regardless of whether the
1249    // subsequent field itself is declared optional. All accesses on a
1250    // nullable receiver must also use `?.`.
1251    let mut prev_was_nullable = false;
1252    for seg in segments {
1253        let nav = if prev_was_nullable { "?." } else { "." };
1254        match seg {
1255            PathSegment::Field(f) => {
1256                if !path_so_far.is_empty() {
1257                    path_so_far.push('.');
1258                }
1259                path_so_far.push_str(f);
1260                // After this call, the receiver is nullable if the field is in
1261                // optional_fields (the Java @Nullable annotation makes the
1262                // return type T? in Kotlin) OR if the incoming receiver was
1263                // already nullable (sticky: `?.` call yields `T?`).
1264                let is_optional = optional_fields.contains(&path_so_far);
1265                out.push_str(nav);
1266                out.push_str(&kotlin_getter(f));
1267                out.push_str("()");
1268                prev_was_nullable = prev_was_nullable || is_optional;
1269            }
1270            PathSegment::ArrayField { name, index } => {
1271                if !path_so_far.is_empty() {
1272                    path_so_far.push('.');
1273                }
1274                path_so_far.push_str(name);
1275                let is_optional = optional_fields.contains(&path_so_far);
1276                out.push_str(nav);
1277                out.push_str(&kotlin_getter(name));
1278                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1279                if *index == 0 {
1280                    out.push_str(&format!("(){safe}.first()"));
1281                } else {
1282                    out.push_str(&format!("(){safe}.get({index})"));
1283                }
1284                // Record the "[0]" suffix so subsequent optional-field checks against
1285                // paths like "choices[0].message.tool_calls" continue to match when the
1286                // optional_fields set uses indexed keys (mirrors the Rust renderer).
1287                path_so_far.push_str("[0]");
1288                prev_was_nullable = prev_was_nullable || is_optional;
1289            }
1290            PathSegment::MapAccess { field, key } => {
1291                if !path_so_far.is_empty() {
1292                    path_so_far.push('.');
1293                }
1294                path_so_far.push_str(field);
1295                let is_optional = optional_fields.contains(&path_so_far);
1296                out.push_str(nav);
1297                out.push_str(&kotlin_getter(field));
1298                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1299                if is_numeric {
1300                    if prev_was_nullable || is_optional {
1301                        out.push_str(&format!("()?.get({key})"));
1302                    } else {
1303                        out.push_str(&format!("().get({key})"));
1304                    }
1305                } else if prev_was_nullable || is_optional {
1306                    out.push_str(&format!("()?.get(\"{key}\")"));
1307                } else {
1308                    out.push_str(&format!("().get(\"{key}\")"));
1309                }
1310                prev_was_nullable = prev_was_nullable || is_optional;
1311            }
1312            PathSegment::Length => {
1313                // .size is a Kotlin property, no () needed.
1314                // If the previous field was nullable, use ?.size
1315                let size_nav = if prev_was_nullable { "?" } else { "" };
1316                out.push_str(&format!("{size_nav}.size"));
1317                prev_was_nullable = false;
1318            }
1319        }
1320    }
1321    out
1322}
1323
1324/// kotlin_android variant of `render_kotlin_with_optionals`.
1325///
1326/// kotlin_android generates Kotlin data classes whose fields are Kotlin
1327/// **properties** (not Java-style getter methods). Every field segment must
1328/// therefore be accessed without parentheses: `result.choices.first().message.content`
1329/// rather than `result.choices().first().message().content()`.
1330///
1331/// The nullable-chain rules are identical to `render_kotlin_with_optionals`:
1332/// once a segment in the path is optional (`T?`) the remainder of the chain
1333/// uses `?.` safe-call syntax.
1334fn render_kotlin_android_with_optionals(
1335    segments: &[PathSegment],
1336    result_var: &str,
1337    optional_fields: &HashSet<String>,
1338) -> String {
1339    let mut out = result_var.to_string();
1340    let mut path_so_far = String::new();
1341    let mut prev_was_nullable = false;
1342    for seg in segments {
1343        let nav = if prev_was_nullable { "?." } else { "." };
1344        match seg {
1345            PathSegment::Field(f) => {
1346                if !path_so_far.is_empty() {
1347                    path_so_far.push('.');
1348                }
1349                path_so_far.push_str(f);
1350                let is_optional = optional_fields.contains(&path_so_far);
1351                out.push_str(nav);
1352                // Property access — no () suffix.
1353                out.push_str(&kotlin_getter(f));
1354                prev_was_nullable = prev_was_nullable || is_optional;
1355            }
1356            PathSegment::ArrayField { name, index } => {
1357                if !path_so_far.is_empty() {
1358                    path_so_far.push('.');
1359                }
1360                path_so_far.push_str(name);
1361                let is_optional = optional_fields.contains(&path_so_far);
1362                out.push_str(nav);
1363                // Property access — no () suffix on the collection itself.
1364                out.push_str(&kotlin_getter(name));
1365                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1366                if *index == 0 {
1367                    out.push_str(&format!("{safe}.first()"));
1368                } else {
1369                    out.push_str(&format!("{safe}.get({index})"));
1370                }
1371                path_so_far.push_str("[0]");
1372                prev_was_nullable = prev_was_nullable || is_optional;
1373            }
1374            PathSegment::MapAccess { field, key } => {
1375                if !path_so_far.is_empty() {
1376                    path_so_far.push('.');
1377                }
1378                path_so_far.push_str(field);
1379                let is_optional = optional_fields.contains(&path_so_far);
1380                out.push_str(nav);
1381                // Property access — no () suffix on the map field.
1382                out.push_str(&kotlin_getter(field));
1383                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1384                if is_numeric {
1385                    if prev_was_nullable || is_optional {
1386                        out.push_str(&format!("?.get({key})"));
1387                    } else {
1388                        out.push_str(&format!(".get({key})"));
1389                    }
1390                } else if prev_was_nullable || is_optional {
1391                    out.push_str(&format!("?.get(\"{key}\")"));
1392                } else {
1393                    out.push_str(&format!(".get(\"{key}\")"));
1394                }
1395                prev_was_nullable = prev_was_nullable || is_optional;
1396            }
1397            PathSegment::Length => {
1398                let size_nav = if prev_was_nullable { "?" } else { "" };
1399                out.push_str(&format!("{size_nav}.size"));
1400                prev_was_nullable = false;
1401            }
1402        }
1403    }
1404    out
1405}
1406
1407/// Non-optional variant of `render_kotlin_android_with_optionals`.
1408///
1409/// Used by `render_accessor` (the path without per-field optionality tracking).
1410fn render_kotlin_android(segments: &[PathSegment], result_var: &str) -> String {
1411    let mut out = result_var.to_string();
1412    for seg in segments {
1413        match seg {
1414            PathSegment::Field(f) => {
1415                out.push('.');
1416                out.push_str(&kotlin_getter(f));
1417                // No () — property access.
1418            }
1419            PathSegment::ArrayField { name, index } => {
1420                out.push('.');
1421                out.push_str(&kotlin_getter(name));
1422                if *index == 0 {
1423                    out.push_str(".first()");
1424                } else {
1425                    out.push_str(&format!(".get({index})"));
1426                }
1427            }
1428            PathSegment::MapAccess { field, key } => {
1429                out.push('.');
1430                out.push_str(&kotlin_getter(field));
1431                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1432                if is_numeric {
1433                    out.push_str(&format!(".get({key})"));
1434                } else {
1435                    out.push_str(&format!(".get(\"{key}\")"));
1436                }
1437            }
1438            PathSegment::Length => {
1439                out.push_str(".size");
1440            }
1441        }
1442    }
1443    out
1444}
1445
1446/// Rust accessor with Option unwrapping for intermediate fields.
1447///
1448/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
1449/// is appended after the field access to unwrap the `Option<T>`.
1450/// When a path is in `method_calls`, `()` is appended to make it a method call.
1451fn render_rust_with_optionals(
1452    segments: &[PathSegment],
1453    result_var: &str,
1454    optional_fields: &HashSet<String>,
1455    method_calls: &HashSet<String>,
1456) -> String {
1457    let mut out = result_var.to_string();
1458    let mut path_so_far = String::new();
1459    for (i, seg) in segments.iter().enumerate() {
1460        let is_leaf = i == segments.len() - 1;
1461        match seg {
1462            PathSegment::Field(f) => {
1463                if !path_so_far.is_empty() {
1464                    path_so_far.push('.');
1465                }
1466                path_so_far.push_str(f);
1467                out.push('.');
1468                out.push_str(&f.to_snake_case());
1469                let is_method = method_calls.contains(&path_so_far);
1470                if is_method {
1471                    out.push_str("()");
1472                    if !is_leaf && optional_fields.contains(&path_so_far) {
1473                        out.push_str(".as_ref().unwrap()");
1474                    }
1475                } else if !is_leaf && optional_fields.contains(&path_so_far) {
1476                    out.push_str(".as_ref().unwrap()");
1477                }
1478            }
1479            PathSegment::ArrayField { name, index } => {
1480                if !path_so_far.is_empty() {
1481                    path_so_far.push('.');
1482                }
1483                path_so_far.push_str(name);
1484                out.push('.');
1485                out.push_str(&name.to_snake_case());
1486                // Option<Vec<T>>: must unwrap the Option before indexing.
1487                // Check both "name" (bare) and "name[0]" (indexed) forms since the
1488                // optional_fields registry may use either convention.
1489                let path_with_idx = format!("{path_so_far}[0]");
1490                let is_opt = optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1491                if is_opt {
1492                    out.push_str(&format!(".as_ref().unwrap()[{index}]"));
1493                } else {
1494                    out.push_str(&format!("[{index}]"));
1495                }
1496                // Record the normalised "[0]" suffix in path_so_far so that deeper
1497                // optional-field keys which include explicit indices (e.g.
1498                // "choices[0].message.tool_calls") continue to match when we check
1499                // subsequent segments.
1500                path_so_far.push_str("[0]");
1501            }
1502            PathSegment::MapAccess { field, key } => {
1503                if !path_so_far.is_empty() {
1504                    path_so_far.push('.');
1505                }
1506                path_so_far.push_str(field);
1507                out.push('.');
1508                out.push_str(&field.to_snake_case());
1509                if key.chars().all(|c| c.is_ascii_digit()) {
1510                    // Check optional both with and without the numeric index suffix.
1511                    let path_with_idx = format!("{path_so_far}[0]");
1512                    let is_opt =
1513                        optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1514                    if is_opt {
1515                        out.push_str(&format!(".as_ref().unwrap()[{key}]"));
1516                    } else {
1517                        out.push_str(&format!("[{key}]"));
1518                    }
1519                    path_so_far.push_str("[0]");
1520                } else {
1521                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
1522                }
1523            }
1524            PathSegment::Length => {
1525                out.push_str(".len()");
1526            }
1527        }
1528    }
1529    out
1530}
1531
1532/// Zig accessor that unwraps optional fields with `.?`.
1533///
1534/// Zig does not allow field access, indexing, or comparisons through `?T`;
1535/// the value must be unwrapped first. Each segment whose path appears in the
1536/// optional-field set is followed by `.?` so the resulting expression is a
1537/// concrete value usable in assertions.
1538///
1539/// Paths in `method_calls` represent tagged-union variant accessors (Rust
1540/// variant getters such as `FormatMetadata::excel()`). In Zig, tagged-union
1541/// variants are accessed via the same dot syntax as struct fields, so the
1542/// segment is emitted as `.{name}` *without* `.?` even if the path also
1543/// appears in `optional_fields`.
1544fn render_zig_with_optionals(
1545    segments: &[PathSegment],
1546    result_var: &str,
1547    optional_fields: &HashSet<String>,
1548    method_calls: &HashSet<String>,
1549) -> String {
1550    let mut out = result_var.to_string();
1551    let mut path_so_far = String::new();
1552    for seg in segments {
1553        match seg {
1554            PathSegment::Field(f) => {
1555                if !path_so_far.is_empty() {
1556                    path_so_far.push('.');
1557                }
1558                path_so_far.push_str(f);
1559                out.push('.');
1560                out.push_str(f);
1561                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1562                    out.push_str(".?");
1563                }
1564            }
1565            PathSegment::ArrayField { name, index } => {
1566                if !path_so_far.is_empty() {
1567                    path_so_far.push('.');
1568                }
1569                path_so_far.push_str(name);
1570                out.push('.');
1571                out.push_str(name);
1572                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1573                    out.push_str(".?");
1574                }
1575                out.push_str(&format!("[{index}]"));
1576            }
1577            PathSegment::MapAccess { field, key } => {
1578                if !path_so_far.is_empty() {
1579                    path_so_far.push('.');
1580                }
1581                path_so_far.push_str(field);
1582                out.push('.');
1583                out.push_str(field);
1584                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1585                    out.push_str(".?");
1586                }
1587                if key.chars().all(|c| c.is_ascii_digit()) {
1588                    out.push_str(&format!("[{key}]"));
1589                } else {
1590                    out.push_str(&format!(".get(\"{key}\")"));
1591                }
1592            }
1593            PathSegment::Length => {
1594                out.push_str(".len");
1595            }
1596        }
1597    }
1598    out
1599}
1600
1601fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
1602    let mut out = result_var.to_string();
1603    for seg in segments {
1604        match seg {
1605            PathSegment::Field(f) => {
1606                out.push('.');
1607                out.push_str(&f.to_pascal_case());
1608            }
1609            PathSegment::ArrayField { name, index } => {
1610                out.push('.');
1611                out.push_str(&name.to_pascal_case());
1612                out.push_str(&format!("[{index}]"));
1613            }
1614            PathSegment::MapAccess { field, key } => {
1615                out.push('.');
1616                out.push_str(&field.to_pascal_case());
1617                if key.chars().all(|c| c.is_ascii_digit()) {
1618                    out.push_str(&format!("[{key}]"));
1619                } else {
1620                    out.push_str(&format!("[\"{key}\"]"));
1621                }
1622            }
1623            PathSegment::Length => {
1624                out.push_str(".Count");
1625            }
1626        }
1627    }
1628    out
1629}
1630
1631fn render_csharp_with_optionals(
1632    segments: &[PathSegment],
1633    result_var: &str,
1634    optional_fields: &HashSet<String>,
1635) -> String {
1636    let mut out = result_var.to_string();
1637    let mut path_so_far = String::new();
1638    for (i, seg) in segments.iter().enumerate() {
1639        let is_leaf = i == segments.len() - 1;
1640        match seg {
1641            PathSegment::Field(f) => {
1642                if !path_so_far.is_empty() {
1643                    path_so_far.push('.');
1644                }
1645                path_so_far.push_str(f);
1646                out.push('.');
1647                out.push_str(&f.to_pascal_case());
1648                if !is_leaf && optional_fields.contains(&path_so_far) {
1649                    out.push('!');
1650                }
1651            }
1652            PathSegment::ArrayField { name, index } => {
1653                if !path_so_far.is_empty() {
1654                    path_so_far.push('.');
1655                }
1656                path_so_far.push_str(name);
1657                out.push('.');
1658                out.push_str(&name.to_pascal_case());
1659                out.push_str(&format!("[{index}]"));
1660            }
1661            PathSegment::MapAccess { field, key } => {
1662                if !path_so_far.is_empty() {
1663                    path_so_far.push('.');
1664                }
1665                path_so_far.push_str(field);
1666                out.push('.');
1667                out.push_str(&field.to_pascal_case());
1668                if key.chars().all(|c| c.is_ascii_digit()) {
1669                    out.push_str(&format!("[{key}]"));
1670                } else {
1671                    out.push_str(&format!("[\"{key}\"]"));
1672                }
1673            }
1674            PathSegment::Length => {
1675                out.push_str(".Count");
1676            }
1677        }
1678    }
1679    out
1680}
1681
1682fn render_php(segments: &[PathSegment], result_var: &str) -> String {
1683    let mut out = result_var.to_string();
1684    for seg in segments {
1685        match seg {
1686            PathSegment::Field(f) => {
1687                out.push_str("->");
1688                // PHP properties are camelCase (per #[php(prop, name = "...")]),
1689                // so convert snake_case field names to camelCase.
1690                out.push_str(&f.to_lower_camel_case());
1691            }
1692            PathSegment::ArrayField { name, index } => {
1693                out.push_str("->");
1694                out.push_str(&name.to_lower_camel_case());
1695                out.push_str(&format!("[{index}]"));
1696            }
1697            PathSegment::MapAccess { field, key } => {
1698                out.push_str("->");
1699                out.push_str(&field.to_lower_camel_case());
1700                out.push_str(&format!("[\"{key}\"]"));
1701            }
1702            PathSegment::Length => {
1703                let current = std::mem::take(&mut out);
1704                out = format!("count({current})");
1705            }
1706        }
1707    }
1708    out
1709}
1710
1711/// PHP accessor that distinguishes between scalar fields (property access: `->camelCase`)
1712/// and non-scalar fields (getter-method access: `->getCamelCase()`).
1713///
1714/// ext-php-rs 0.15.x exposes scalar fields via `#[php(prop)]` as PHP properties, but
1715/// non-scalar fields (Named structs, `Vec<Named>`, `Map`, etc.) require a `#[php(getter)]`
1716/// method because `get_method_props` is `todo!()` in ext-php-rs-derive 0.11.7.
1717/// The generated getter method name is `get{CamelCase}` (stripping the `get_` prefix and
1718/// converting the camelCase remainder to a PHP property name), so e2e assertions must call
1719/// `->getCamelCase()` for those fields.
1720///
1721/// `getter_map` carries the per-`(owner_type, field_name)` classification along with the
1722/// chain-resolution metadata required to walk multi-segment paths through the IR's nested
1723/// type graph. Each path segment is classified using the *current* owner type, then the
1724/// owner cursor advances to the field's referenced `Named` type (if any) for the next
1725/// segment. When `root_type` is unset the renderer falls back to the legacy bare-name
1726/// union, which is unsafe but preserves backwards compatibility for callers that have
1727/// not wired type resolution.
1728fn render_php_with_getters(segments: &[PathSegment], result_var: &str, getter_map: &PhpGetterMap) -> String {
1729    let mut out = result_var.to_string();
1730    let mut current_type: Option<String> = getter_map.root_type.clone();
1731    for seg in segments {
1732        match seg {
1733            PathSegment::Field(f) => {
1734                let camel = f.to_lower_camel_case();
1735                if getter_map.needs_getter(current_type.as_deref(), f.as_str()) {
1736                    // Non-scalar field: ext-php-rs emits a `get{CamelCase}()` method.
1737                    // The `get_` prefix is stripped by ext-php-rs when it derives the
1738                    // PHP property name, but the Rust method ident is `get_{camelCase}`,
1739                    // so the PHP call is `->get{CamelCase}()`.
1740                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1741                    out.push_str("->");
1742                    out.push_str(&getter);
1743                    out.push_str("()");
1744                } else {
1745                    out.push_str("->");
1746                    out.push_str(&camel);
1747                }
1748                current_type = getter_map.advance(current_type.as_deref(), f.as_str());
1749            }
1750            PathSegment::ArrayField { name, index } => {
1751                let camel = name.to_lower_camel_case();
1752                if getter_map.needs_getter(current_type.as_deref(), name.as_str()) {
1753                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1754                    out.push_str("->");
1755                    out.push_str(&getter);
1756                    out.push_str("()");
1757                } else {
1758                    out.push_str("->");
1759                    out.push_str(&camel);
1760                }
1761                out.push_str(&format!("[{index}]"));
1762                current_type = getter_map.advance(current_type.as_deref(), name.as_str());
1763            }
1764            PathSegment::MapAccess { field, key } => {
1765                let camel = field.to_lower_camel_case();
1766                if getter_map.needs_getter(current_type.as_deref(), field.as_str()) {
1767                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1768                    out.push_str("->");
1769                    out.push_str(&getter);
1770                    out.push_str("()");
1771                } else {
1772                    out.push_str("->");
1773                    out.push_str(&camel);
1774                }
1775                out.push_str(&format!("[\"{key}\"]"));
1776                current_type = getter_map.advance(current_type.as_deref(), field.as_str());
1777            }
1778            PathSegment::Length => {
1779                let current = std::mem::take(&mut out);
1780                out = format!("count({current})");
1781            }
1782        }
1783    }
1784    out
1785}
1786
1787fn render_r(segments: &[PathSegment], result_var: &str) -> String {
1788    let mut out = result_var.to_string();
1789    for seg in segments {
1790        match seg {
1791            PathSegment::Field(f) => {
1792                out.push('$');
1793                out.push_str(f);
1794            }
1795            PathSegment::ArrayField { name, index } => {
1796                out.push('$');
1797                out.push_str(name);
1798                // R uses 1-based indexing.
1799                out.push_str(&format!("[[{}]]", index + 1));
1800            }
1801            PathSegment::MapAccess { field, key } => {
1802                out.push('$');
1803                out.push_str(field);
1804                out.push_str(&format!("[[\"{key}\"]]"));
1805            }
1806            PathSegment::Length => {
1807                let current = std::mem::take(&mut out);
1808                out = format!("length({current})");
1809            }
1810        }
1811    }
1812    out
1813}
1814
1815fn render_c(segments: &[PathSegment], result_var: &str) -> String {
1816    let mut parts = Vec::new();
1817    let mut trailing_length = false;
1818    for seg in segments {
1819        match seg {
1820            PathSegment::Field(f) => parts.push(f.to_snake_case()),
1821            PathSegment::ArrayField { name, .. } => parts.push(name.to_snake_case()),
1822            PathSegment::MapAccess { field, key } => {
1823                parts.push(field.to_snake_case());
1824                parts.push(key.clone());
1825            }
1826            PathSegment::Length => {
1827                trailing_length = true;
1828            }
1829        }
1830    }
1831    let suffix = parts.join("_");
1832    if trailing_length {
1833        format!("result_{suffix}_count({result_var})")
1834    } else {
1835        format!("result_{suffix}({result_var})")
1836    }
1837}
1838
1839/// Dart accessor using camelCase field names (FRB v2 convention).
1840///
1841/// FRB v2 generates Dart property getters with camelCase names for every
1842/// snake_case Rust field, so `snake_case_field` becomes `snakeCaseField`.
1843/// Array fields index with `[N]`; map fields use `["key"]` or `[N]` notation.
1844/// Length/count segments use `.length` (Dart `List.length`).
1845fn render_dart(segments: &[PathSegment], result_var: &str) -> String {
1846    let mut out = result_var.to_string();
1847    for seg in segments {
1848        match seg {
1849            PathSegment::Field(f) => {
1850                out.push('.');
1851                out.push_str(&f.to_lower_camel_case());
1852            }
1853            PathSegment::ArrayField { name, index } => {
1854                out.push('.');
1855                out.push_str(&name.to_lower_camel_case());
1856                out.push_str(&format!("[{index}]"));
1857            }
1858            PathSegment::MapAccess { field, key } => {
1859                out.push('.');
1860                out.push_str(&field.to_lower_camel_case());
1861                if key.chars().all(|c| c.is_ascii_digit()) {
1862                    out.push_str(&format!("[{key}]"));
1863                } else {
1864                    out.push_str(&format!("[\"{key}\"]"));
1865                }
1866            }
1867            PathSegment::Length => {
1868                out.push_str(".length");
1869            }
1870        }
1871    }
1872    out
1873}
1874
1875/// Dart accessor with optional-safe navigation using `?.` (FRB v2 convention).
1876///
1877/// When an intermediate field is in `optional_fields`, the next segment uses
1878/// `?.` safe-call navigation instead of `.` to avoid a null-dereference on
1879/// a nullable Dart type.  Field names are camelCase (FRB v2 generation rule).
1880fn render_dart_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1881    let mut out = result_var.to_string();
1882    let mut path_so_far = String::new();
1883    let mut prev_was_nullable = false;
1884    for seg in segments {
1885        let nav = if prev_was_nullable { "?." } else { "." };
1886        match seg {
1887            PathSegment::Field(f) => {
1888                if !path_so_far.is_empty() {
1889                    path_so_far.push('.');
1890                }
1891                path_so_far.push_str(f);
1892                let is_optional = optional_fields.contains(&path_so_far);
1893                out.push_str(nav);
1894                out.push_str(&f.to_lower_camel_case());
1895                prev_was_nullable = is_optional;
1896            }
1897            PathSegment::ArrayField { name, index } => {
1898                if !path_so_far.is_empty() {
1899                    path_so_far.push('.');
1900                }
1901                path_so_far.push_str(name);
1902                let is_optional = optional_fields.contains(&path_so_far);
1903                out.push_str(nav);
1904                out.push_str(&name.to_lower_camel_case());
1905                // FRB models `Option<Vec<T>>` as `List<T>?` — only force-unwrap when the field
1906                // is registered as optional. Adding `!` to a non-nullable receiver is a Dart
1907                // compile-time error ("unnecessary non-null assertion").
1908                if is_optional {
1909                    out.push('!');
1910                }
1911                out.push_str(&format!("[{index}]"));
1912                prev_was_nullable = false;
1913            }
1914            PathSegment::MapAccess { field, key } => {
1915                if !path_so_far.is_empty() {
1916                    path_so_far.push('.');
1917                }
1918                path_so_far.push_str(field);
1919                let is_optional = optional_fields.contains(&path_so_far);
1920                out.push_str(nav);
1921                out.push_str(&field.to_lower_camel_case());
1922                if key.chars().all(|c| c.is_ascii_digit()) {
1923                    out.push_str(&format!("[{key}]"));
1924                } else {
1925                    out.push_str(&format!("[\"{key}\"]"));
1926                }
1927                prev_was_nullable = is_optional;
1928            }
1929            PathSegment::Length => {
1930                // Use `?.length` when the receiver is optional — emitting `.length` against
1931                // a `List<T>?` is a Dart sound-null-safety error.
1932                out.push_str(nav);
1933                out.push_str("length");
1934                prev_was_nullable = false;
1935            }
1936        }
1937    }
1938    out
1939}
1940
1941#[cfg(test)]
1942mod tests {
1943    use super::*;
1944
1945    fn make_resolver() -> FieldResolver {
1946        let mut fields = HashMap::new();
1947        fields.insert("title".to_string(), "metadata.document.title".to_string());
1948        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1949        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
1950        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
1951        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
1952        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
1953        let mut optional = HashSet::new();
1954        optional.insert("metadata.document.title".to_string());
1955        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1956    }
1957
1958    fn make_resolver_with_doc_optional() -> FieldResolver {
1959        let mut fields = HashMap::new();
1960        fields.insert("title".to_string(), "metadata.document.title".to_string());
1961        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1962        let mut optional = HashSet::new();
1963        optional.insert("document".to_string());
1964        optional.insert("metadata.document.title".to_string());
1965        optional.insert("metadata.document".to_string());
1966        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1967    }
1968
1969    #[test]
1970    fn test_resolve_alias() {
1971        let r = make_resolver();
1972        assert_eq!(r.resolve("title"), "metadata.document.title");
1973    }
1974
1975    #[test]
1976    fn test_resolve_passthrough() {
1977        let r = make_resolver();
1978        assert_eq!(r.resolve("content"), "content");
1979    }
1980
1981    #[test]
1982    fn test_is_optional() {
1983        let r = make_resolver();
1984        assert!(r.is_optional("metadata.document.title"));
1985        assert!(!r.is_optional("content"));
1986    }
1987
1988    #[test]
1989    fn test_accessor_rust_struct() {
1990        let r = make_resolver();
1991        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
1992    }
1993
1994    #[test]
1995    fn test_accessor_rust_map() {
1996        let r = make_resolver();
1997        assert_eq!(
1998            r.accessor("tags", "rust", "result"),
1999            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
2000        );
2001    }
2002
2003    #[test]
2004    fn test_accessor_python() {
2005        let r = make_resolver();
2006        assert_eq!(
2007            r.accessor("title", "python", "result"),
2008            "result.metadata.document.title"
2009        );
2010    }
2011
2012    #[test]
2013    fn test_accessor_go() {
2014        let r = make_resolver();
2015        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
2016    }
2017
2018    #[test]
2019    fn test_accessor_go_initialism_fields() {
2020        let mut fields = std::collections::HashMap::new();
2021        fields.insert("content".to_string(), "html".to_string());
2022        fields.insert("link_url".to_string(), "links.url".to_string());
2023        let r = FieldResolver::new(
2024            &fields,
2025            &HashSet::new(),
2026            &HashSet::new(),
2027            &HashSet::new(),
2028            &HashSet::new(),
2029        );
2030        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
2031        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
2032        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
2033        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
2034        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
2035        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
2036        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
2037        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
2038    }
2039
2040    #[test]
2041    fn test_accessor_typescript() {
2042        let r = make_resolver();
2043        assert_eq!(
2044            r.accessor("title", "typescript", "result"),
2045            "result.metadata.document.title"
2046        );
2047    }
2048
2049    #[test]
2050    fn test_accessor_typescript_snake_to_camel() {
2051        let r = make_resolver();
2052        assert_eq!(
2053            r.accessor("og", "typescript", "result"),
2054            "result.metadata.document.openGraph"
2055        );
2056        assert_eq!(
2057            r.accessor("twitter", "typescript", "result"),
2058            "result.metadata.document.twitterCard"
2059        );
2060        assert_eq!(
2061            r.accessor("canonical", "typescript", "result"),
2062            "result.metadata.document.canonicalUrl"
2063        );
2064    }
2065
2066    #[test]
2067    fn test_accessor_typescript_map_snake_to_camel() {
2068        let r = make_resolver();
2069        assert_eq!(
2070            r.accessor("og_tag", "typescript", "result"),
2071            "result.metadata.openGraphTags[\"og_title\"]"
2072        );
2073    }
2074
2075    #[test]
2076    fn test_accessor_typescript_numeric_index_is_unquoted() {
2077        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
2078        // must emit numeric bracket access (`[0]`) not string-keyed access
2079        // (`["0"]`), which would return undefined on arrays.
2080        let mut fields = HashMap::new();
2081        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
2082        let r = FieldResolver::new(
2083            &fields,
2084            &HashSet::new(),
2085            &HashSet::new(),
2086            &HashSet::new(),
2087            &HashSet::new(),
2088        );
2089        assert_eq!(
2090            r.accessor("first_score", "typescript", "result"),
2091            "result.results[0].relevanceScore"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_accessor_node_alias() {
2097        let r = make_resolver();
2098        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
2099    }
2100
2101    #[test]
2102    fn test_accessor_wasm_camel_case() {
2103        let r = make_resolver();
2104        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
2105        assert_eq!(
2106            r.accessor("twitter", "wasm", "result"),
2107            "result.metadata.document.twitterCard"
2108        );
2109        assert_eq!(
2110            r.accessor("canonical", "wasm", "result"),
2111            "result.metadata.document.canonicalUrl"
2112        );
2113    }
2114
2115    #[test]
2116    fn test_accessor_wasm_map_access() {
2117        let r = make_resolver();
2118        assert_eq!(
2119            r.accessor("og_tag", "wasm", "result"),
2120            "result.metadata.openGraphTags.get(\"og_title\")"
2121        );
2122    }
2123
2124    #[test]
2125    fn test_accessor_java() {
2126        let r = make_resolver();
2127        assert_eq!(
2128            r.accessor("title", "java", "result"),
2129            "result.metadata().document().title()"
2130        );
2131    }
2132
2133    #[test]
2134    fn test_accessor_kotlin_uses_kotlin_collection_idioms() {
2135        let mut fields = HashMap::new();
2136        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2137        fields.insert("node_count".to_string(), "nodes.length".to_string());
2138        let mut arrays = HashSet::new();
2139        arrays.insert("nodes".to_string());
2140        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &arrays, &HashSet::new());
2141        assert_eq!(
2142            r.accessor("first_node_name", "kotlin", "result"),
2143            "result.nodes().first().name()"
2144        );
2145        assert_eq!(r.accessor("node_count", "kotlin", "result"), "result.nodes().size");
2146    }
2147
2148    #[test]
2149    fn test_accessor_kotlin_uses_safe_calls_for_optional_prefixes() {
2150        let r = make_resolver_with_doc_optional();
2151        assert_eq!(
2152            r.accessor("title", "kotlin", "result"),
2153            "result.metadata().document()?.title()"
2154        );
2155    }
2156
2157    #[test]
2158    fn test_accessor_kotlin_uses_safe_calls_for_optional_arrays_and_maps() {
2159        let mut fields = HashMap::new();
2160        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2161        fields.insert("tag".to_string(), "tags[name]".to_string());
2162        let mut optional = HashSet::new();
2163        optional.insert("nodes".to_string());
2164        optional.insert("tags".to_string());
2165        let mut arrays = HashSet::new();
2166        arrays.insert("nodes".to_string());
2167        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2168        assert_eq!(
2169            r.accessor("first_node_name", "kotlin", "result"),
2170            "result.nodes()?.first()?.name()"
2171        );
2172        assert_eq!(r.accessor("tag", "kotlin", "result"), "result.tags()?.get(\"name\")");
2173    }
2174
2175    /// Regression: optional-field keys with explicit `[0]` indices (e.g.
2176    /// `"choices[0].message.tool_calls"`) were not matched by
2177    /// `render_kotlin_with_optionals` because `path_so_far` omitted the `[0]`
2178    /// suffix after traversing an ArrayField segment. Fix: append `"[0]"` to
2179    /// `path_so_far` after each ArrayField, mirroring the Rust renderer.
2180    #[test]
2181    fn test_accessor_kotlin_optional_field_after_indexed_array() {
2182        // "choices[0].message.tool_calls" is optional; the path is accessed as
2183        // choices[0].message.tool_calls[0].function.name.
2184        let mut fields = HashMap::new();
2185        fields.insert(
2186            "tool_call_name".to_string(),
2187            "choices[0].message.tool_calls[0].function.name".to_string(),
2188        );
2189        let mut optional = HashSet::new();
2190        optional.insert("choices[0].message.tool_calls".to_string());
2191        let mut arrays = HashSet::new();
2192        arrays.insert("choices".to_string());
2193        arrays.insert("choices[0].message.tool_calls".to_string());
2194        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2195        let expr = r.accessor("tool_call_name", "kotlin", "result");
2196        // toolCalls() is optional so it must use `?.` before `.first()`.
2197        assert!(
2198            expr.contains("toolCalls()?.first()"),
2199            "expected toolCalls()?.first() for optional list, got: {expr}"
2200        );
2201    }
2202
2203    #[test]
2204    fn test_accessor_csharp() {
2205        let r = make_resolver();
2206        assert_eq!(
2207            r.accessor("title", "csharp", "result"),
2208            "result.Metadata.Document.Title"
2209        );
2210    }
2211
2212    #[test]
2213    fn test_accessor_php() {
2214        let r = make_resolver();
2215        assert_eq!(
2216            r.accessor("title", "php", "$result"),
2217            "$result->metadata->document->title"
2218        );
2219    }
2220
2221    #[test]
2222    fn test_accessor_r() {
2223        let r = make_resolver();
2224        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
2225    }
2226
2227    #[test]
2228    fn test_accessor_c() {
2229        let r = make_resolver();
2230        assert_eq!(
2231            r.accessor("title", "c", "result"),
2232            "result_metadata_document_title(result)"
2233        );
2234    }
2235
2236    #[test]
2237    fn test_rust_unwrap_binding() {
2238        let r = make_resolver();
2239        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
2240        assert_eq!(var, "metadata_document_title");
2241        assert!(binding.contains("as_ref().map(|v| v.to_string()).unwrap_or_default()"));
2242    }
2243
2244    #[test]
2245    fn test_rust_unwrap_binding_non_optional() {
2246        let r = make_resolver();
2247        assert!(r.rust_unwrap_binding("content", "result").is_none());
2248    }
2249
2250    #[test]
2251    fn test_rust_unwrap_binding_collapses_double_underscore() {
2252        // When an alias resolves to a path with `[]` (e.g. `json_ld.name` →
2253        // `json_ld[].name`), the naive replace previously yielded `json_ld__name`,
2254        // which trips Rust's non_snake_case lint under -D warnings. The local
2255        // binding name must collapse consecutive underscores into one.
2256        let mut aliases = HashMap::new();
2257        aliases.insert("json_ld.name".to_string(), "json_ld[].name".to_string());
2258        let mut optional = HashSet::new();
2259        optional.insert("json_ld[].name".to_string());
2260        let mut array = HashSet::new();
2261        array.insert("json_ld".to_string());
2262        let result_fields = HashSet::new();
2263        let method_calls = HashSet::new();
2264        let r = FieldResolver::new(&aliases, &optional, &result_fields, &array, &method_calls);
2265        let (_binding, var) = r.rust_unwrap_binding("json_ld.name", "result").unwrap();
2266        assert_eq!(var, "json_ld_name");
2267    }
2268
2269    #[test]
2270    fn test_direct_field_no_alias() {
2271        let r = make_resolver();
2272        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2273        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
2274    }
2275
2276    #[test]
2277    fn test_accessor_rust_with_optionals() {
2278        let r = make_resolver_with_doc_optional();
2279        assert_eq!(
2280            r.accessor("title", "rust", "result"),
2281            "result.metadata.document.as_ref().unwrap().title"
2282        );
2283    }
2284
2285    #[test]
2286    fn test_accessor_csharp_with_optionals() {
2287        let r = make_resolver_with_doc_optional();
2288        assert_eq!(
2289            r.accessor("title", "csharp", "result"),
2290            "result.Metadata.Document!.Title"
2291        );
2292    }
2293
2294    #[test]
2295    fn test_accessor_rust_non_optional_field() {
2296        let r = make_resolver();
2297        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2298    }
2299
2300    #[test]
2301    fn test_accessor_csharp_non_optional_field() {
2302        let r = make_resolver();
2303        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
2304    }
2305
2306    #[test]
2307    fn test_accessor_rust_method_call() {
2308        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
2309        let mut fields = HashMap::new();
2310        fields.insert(
2311            "excel_sheet_count".to_string(),
2312            "metadata.format.excel.sheet_count".to_string(),
2313        );
2314        let mut optional = HashSet::new();
2315        optional.insert("metadata.format".to_string());
2316        optional.insert("metadata.format.excel".to_string());
2317        let mut method_calls = HashSet::new();
2318        method_calls.insert("metadata.format.excel".to_string());
2319        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
2320        assert_eq!(
2321            r.accessor("excel_sheet_count", "rust", "result"),
2322            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
2323        );
2324    }
2325
2326    // ---------------------------------------------------------------------------
2327    // PHP getter-method tests (ext-php-rs 0.15.x `#[php(getter)]` vs `#[php(prop)]`)
2328    // ---------------------------------------------------------------------------
2329
2330    fn make_php_getter_resolver() -> FieldResolver {
2331        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2332        getters.insert(
2333            "Root".to_string(),
2334            ["metadata".to_string(), "links".to_string()].into_iter().collect(),
2335        );
2336        let map = PhpGetterMap {
2337            getters,
2338            field_types: HashMap::new(),
2339            root_type: Some("Root".to_string()),
2340            all_fields: HashMap::new(),
2341        };
2342        FieldResolver::new_with_php_getters(
2343            &HashMap::new(),
2344            &HashSet::new(),
2345            &HashSet::new(),
2346            &HashSet::new(),
2347            &HashSet::new(),
2348            &HashMap::new(),
2349            map,
2350        )
2351    }
2352
2353    #[test]
2354    fn render_php_uses_getter_method_for_non_scalar_field() {
2355        let r = make_php_getter_resolver();
2356        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->getMetadata()");
2357    }
2358
2359    #[test]
2360    fn render_php_uses_property_for_scalar_field() {
2361        let r = make_php_getter_resolver();
2362        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2363    }
2364
2365    #[test]
2366    fn render_php_nested_non_scalar_uses_getter_then_property() {
2367        let mut fields = HashMap::new();
2368        fields.insert("title".to_string(), "metadata.title".to_string());
2369        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2370        getters.insert("Root".to_string(), ["metadata".to_string()].into_iter().collect());
2371        // No entry for Metadata.title → scalar by default.
2372        getters.insert("Metadata".to_string(), HashSet::new());
2373        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2374        field_types.insert(
2375            "Root".to_string(),
2376            [("metadata".to_string(), "Metadata".to_string())].into_iter().collect(),
2377        );
2378        let map = PhpGetterMap {
2379            getters,
2380            field_types,
2381            root_type: Some("Root".to_string()),
2382            all_fields: HashMap::new(),
2383        };
2384        let r = FieldResolver::new_with_php_getters(
2385            &fields,
2386            &HashSet::new(),
2387            &HashSet::new(),
2388            &HashSet::new(),
2389            &HashSet::new(),
2390            &HashMap::new(),
2391            map,
2392        );
2393        // `metadata` → `->getMetadata()`, then `title` (scalar on returned object) → `->title`
2394        assert_eq!(r.accessor("title", "php", "$result"), "$result->getMetadata()->title");
2395    }
2396
2397    #[test]
2398    fn render_php_array_field_uses_getter_when_non_scalar() {
2399        let mut fields = HashMap::new();
2400        fields.insert("first_link".to_string(), "links[0]".to_string());
2401        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2402        getters.insert("Root".to_string(), ["links".to_string()].into_iter().collect());
2403        let map = PhpGetterMap {
2404            getters,
2405            field_types: HashMap::new(),
2406            root_type: Some("Root".to_string()),
2407            all_fields: HashMap::new(),
2408        };
2409        let r = FieldResolver::new_with_php_getters(
2410            &fields,
2411            &HashSet::new(),
2412            &HashSet::new(),
2413            &HashSet::new(),
2414            &HashSet::new(),
2415            &HashMap::new(),
2416            map,
2417        );
2418        assert_eq!(r.accessor("first_link", "php", "$result"), "$result->getLinks()[0]");
2419    }
2420
2421    #[test]
2422    fn render_php_falls_back_to_property_when_getter_fields_empty() {
2423        // With empty php_getter_map the resolver uses the plain `render_php` path,
2424        // which emits `->camelCase` for every field regardless of scalar-ness.
2425        let r = FieldResolver::new(
2426            &HashMap::new(),
2427            &HashSet::new(),
2428            &HashSet::new(),
2429            &HashSet::new(),
2430            &HashSet::new(),
2431        );
2432        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2433        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->metadata");
2434    }
2435
2436    // Regression: bare-name HashSet classification produced false getters when two
2437    // types shared a field name with different scalarness (kreuzcrawl `content`
2438    // collision between CrawlConfig.content: ContentConfig and MarkdownResult.content: String).
2439    #[test]
2440    fn render_php_with_getters_distinguishes_same_field_name_on_different_types() {
2441        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2442        // A.content is non-scalar.
2443        getters.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2444        // B.content is scalar — explicit empty set.
2445        getters.insert("B".to_string(), HashSet::new());
2446        // Both A and B declare a "content" field — needed so the per-type
2447        // classification is consulted (not fallback bare-name union).
2448        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2449        all_fields.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2450        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2451        let map_a = PhpGetterMap {
2452            getters: getters.clone(),
2453            field_types: HashMap::new(),
2454            root_type: Some("A".to_string()),
2455            all_fields: all_fields.clone(),
2456        };
2457        let map_b = PhpGetterMap {
2458            getters,
2459            field_types: HashMap::new(),
2460            root_type: Some("B".to_string()),
2461            all_fields,
2462        };
2463        let r_a = FieldResolver::new_with_php_getters(
2464            &HashMap::new(),
2465            &HashSet::new(),
2466            &HashSet::new(),
2467            &HashSet::new(),
2468            &HashSet::new(),
2469            &HashMap::new(),
2470            map_a,
2471        );
2472        let r_b = FieldResolver::new_with_php_getters(
2473            &HashMap::new(),
2474            &HashSet::new(),
2475            &HashSet::new(),
2476            &HashSet::new(),
2477            &HashSet::new(),
2478            &HashMap::new(),
2479            map_b,
2480        );
2481        assert_eq!(r_a.accessor("content", "php", "$a"), "$a->getContent()");
2482        assert_eq!(r_b.accessor("content", "php", "$b"), "$b->content");
2483    }
2484
2485    // Regression: the chain renderer must advance current_type through the IR's
2486    // nested-type graph so a scalar field on a nested type is not falsely
2487    // classified as needing a getter because some other type uses the same name.
2488    #[test]
2489    fn render_php_with_getters_chains_through_correct_type() {
2490        let mut fields = HashMap::new();
2491        fields.insert("nested_content".to_string(), "inner.content".to_string());
2492        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2493        // Outer.inner is non-scalar (struct B).
2494        getters.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2495        // B.content is scalar.
2496        getters.insert("B".to_string(), HashSet::new());
2497        // Decoy: another type with non-scalar `content` field — used to verify
2498        // the legacy bare-name union would have produced the wrong answer.
2499        getters.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2500        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2501        field_types.insert(
2502            "Outer".to_string(),
2503            [("inner".to_string(), "B".to_string())].into_iter().collect(),
2504        );
2505        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2506        all_fields.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2507        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2508        all_fields.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2509        let map = PhpGetterMap {
2510            getters,
2511            field_types,
2512            root_type: Some("Outer".to_string()),
2513            all_fields,
2514        };
2515        let r = FieldResolver::new_with_php_getters(
2516            &fields,
2517            &HashSet::new(),
2518            &HashSet::new(),
2519            &HashSet::new(),
2520            &HashSet::new(),
2521            &HashMap::new(),
2522            map,
2523        );
2524        assert_eq!(
2525            r.accessor("nested_content", "php", "$result"),
2526            "$result->getInner()->content"
2527        );
2528    }
2529}