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