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