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