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 parts = Vec::new();
2044    let mut trailing_length = false;
2045    for seg in segments {
2046        match seg {
2047            PathSegment::Field(f) => parts.push(f.to_snake_case()),
2048            PathSegment::ArrayField { name, .. } => parts.push(name.to_snake_case()),
2049            PathSegment::MapAccess { field, key } => {
2050                parts.push(field.to_snake_case());
2051                parts.push(key.clone());
2052            }
2053            PathSegment::Length => {
2054                trailing_length = true;
2055            }
2056        }
2057    }
2058    let suffix = parts.join("_");
2059    if trailing_length {
2060        format!("result_{suffix}_count({result_var})")
2061    } else {
2062        format!("result_{suffix}({result_var})")
2063    }
2064}
2065
2066/// Dart accessor using camelCase field names (FRB v2 convention).
2067///
2068/// FRB v2 generates Dart property getters with camelCase names for every
2069/// snake_case Rust field, so `snake_case_field` becomes `snakeCaseField`.
2070/// Array fields index with `[N]`; map fields use `["key"]` or `[N]` notation.
2071/// Length/count segments use `.length` (Dart `List.length`).
2072fn render_dart(segments: &[PathSegment], result_var: &str) -> String {
2073    let mut out = result_var.to_string();
2074    for seg in segments {
2075        match seg {
2076            PathSegment::Field(f) => {
2077                out.push('.');
2078                out.push_str(&f.to_lower_camel_case());
2079            }
2080            PathSegment::ArrayField { name, index } => {
2081                out.push('.');
2082                out.push_str(&name.to_lower_camel_case());
2083                out.push_str(&format!("[{index}]"));
2084            }
2085            PathSegment::MapAccess { field, key } => {
2086                out.push('.');
2087                out.push_str(&field.to_lower_camel_case());
2088                if key.chars().all(|c| c.is_ascii_digit()) {
2089                    out.push_str(&format!("[{key}]"));
2090                } else {
2091                    out.push_str(&format!("[\"{key}\"]"));
2092                }
2093            }
2094            PathSegment::Length => {
2095                out.push_str(".length");
2096            }
2097        }
2098    }
2099    out
2100}
2101
2102/// Dart accessor with optional-safe navigation using `?.` (FRB v2 convention).
2103///
2104/// When an intermediate field is in `optional_fields`, the next segment uses
2105/// `?.` safe-call navigation instead of `.` to avoid a null-dereference on
2106/// a nullable Dart type.  Field names are camelCase (FRB v2 generation rule).
2107fn render_dart_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
2108    let mut out = result_var.to_string();
2109    // Two parallel path trackers:
2110    //   `path_so_far`           — dot-joined field names without array indices
2111    //                             (e.g. `choices.message.tool_calls`).
2112    //   `path_with_indices`     — same path but retaining `[N]` segments from
2113    //                             prior ArrayField segments (e.g.
2114    //                             `choices[0].message.tool_calls`).
2115    // `fields_optional` in alef.toml may list either form; we check both.
2116    let mut path_so_far = String::new();
2117    let mut path_with_indices = String::new();
2118    let mut prev_was_nullable = false;
2119    let is_optional =
2120        |bare: &str, indexed: &str| -> bool { optional_fields.contains(bare) || optional_fields.contains(indexed) };
2121    for seg in segments {
2122        let nav = if prev_was_nullable { "?." } else { "." };
2123        match seg {
2124            PathSegment::Field(f) => {
2125                if !path_so_far.is_empty() {
2126                    path_so_far.push('.');
2127                    path_with_indices.push('.');
2128                }
2129                path_so_far.push_str(f);
2130                path_with_indices.push_str(f);
2131                let optional = is_optional(&path_so_far, &path_with_indices);
2132                out.push_str(nav);
2133                out.push_str(&f.to_lower_camel_case());
2134                prev_was_nullable = optional;
2135            }
2136            PathSegment::ArrayField { name, index } => {
2137                if !path_so_far.is_empty() {
2138                    path_so_far.push('.');
2139                    path_with_indices.push('.');
2140                }
2141                path_so_far.push_str(name);
2142                path_with_indices.push_str(name);
2143                let optional = is_optional(&path_so_far, &path_with_indices);
2144                out.push_str(nav);
2145                out.push_str(&name.to_lower_camel_case());
2146                // FRB models `Option<Vec<T>>` as `List<T>?` — force-unwrap when the field
2147                // is registered as optional. Adding `!` to a non-nullable receiver is a Dart
2148                // compile-time error ("unnecessary non-null assertion").
2149                if optional {
2150                    out.push('!');
2151                }
2152                out.push_str(&format!("[{index}]"));
2153                path_with_indices.push_str(&format!("[{index}]"));
2154                prev_was_nullable = false;
2155            }
2156            PathSegment::MapAccess { field, key } => {
2157                if !path_so_far.is_empty() {
2158                    path_so_far.push('.');
2159                    path_with_indices.push('.');
2160                }
2161                path_so_far.push_str(field);
2162                path_with_indices.push_str(field);
2163                let optional = is_optional(&path_so_far, &path_with_indices);
2164                out.push_str(nav);
2165                out.push_str(&field.to_lower_camel_case());
2166                if key.chars().all(|c| c.is_ascii_digit()) {
2167                    out.push_str(&format!("[{key}]"));
2168                    path_with_indices.push_str(&format!("[{key}]"));
2169                } else {
2170                    out.push_str(&format!("[\"{key}\"]"));
2171                    path_with_indices.push_str(&format!("[\"{key}\"]"));
2172                }
2173                prev_was_nullable = optional;
2174            }
2175            PathSegment::Length => {
2176                // Use `?.length` when the receiver is optional — emitting `.length` against
2177                // a `List<T>?` is a Dart sound-null-safety error.
2178                out.push_str(nav);
2179                out.push_str("length");
2180                prev_was_nullable = false;
2181            }
2182        }
2183    }
2184    out
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::*;
2190
2191    fn make_resolver() -> FieldResolver {
2192        let mut fields = HashMap::new();
2193        fields.insert("title".to_string(), "metadata.document.title".to_string());
2194        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
2195        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
2196        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
2197        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
2198        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
2199        let mut optional = HashSet::new();
2200        optional.insert("metadata.document.title".to_string());
2201        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
2202    }
2203
2204    fn make_resolver_with_doc_optional() -> FieldResolver {
2205        let mut fields = HashMap::new();
2206        fields.insert("title".to_string(), "metadata.document.title".to_string());
2207        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
2208        let mut optional = HashSet::new();
2209        optional.insert("document".to_string());
2210        optional.insert("metadata.document.title".to_string());
2211        optional.insert("metadata.document".to_string());
2212        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
2213    }
2214
2215    #[test]
2216    fn test_resolve_alias() {
2217        let r = make_resolver();
2218        assert_eq!(r.resolve("title"), "metadata.document.title");
2219    }
2220
2221    #[test]
2222    fn test_resolve_passthrough() {
2223        let r = make_resolver();
2224        assert_eq!(r.resolve("content"), "content");
2225    }
2226
2227    #[test]
2228    fn test_is_optional() {
2229        let r = make_resolver();
2230        assert!(r.is_optional("metadata.document.title"));
2231        assert!(!r.is_optional("content"));
2232    }
2233
2234    #[test]
2235    fn is_optional_strips_namespace_prefix() {
2236        let fields = HashMap::new();
2237        let mut optional = HashSet::new();
2238        optional.insert("action_results.data".to_string());
2239        let result_fields: HashSet<String> = ["action_results".to_string()].into_iter().collect();
2240        let r = FieldResolver::new(&fields, &optional, &result_fields, &HashSet::new(), &HashSet::new());
2241        // `interaction.` is a virtual namespace prefix — strip and re-check.
2242        assert!(r.is_optional("interaction.action_results[0].data"));
2243        // Still finds it without the prefix.
2244        assert!(r.is_optional("action_results[0].data"));
2245    }
2246
2247    #[test]
2248    fn test_accessor_rust_struct() {
2249        let r = make_resolver();
2250        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
2251    }
2252
2253    #[test]
2254    fn test_accessor_rust_map() {
2255        let r = make_resolver();
2256        assert_eq!(
2257            r.accessor("tags", "rust", "result"),
2258            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
2259        );
2260    }
2261
2262    #[test]
2263    fn test_accessor_python() {
2264        let r = make_resolver();
2265        assert_eq!(
2266            r.accessor("title", "python", "result"),
2267            "result.metadata.document.title"
2268        );
2269    }
2270
2271    #[test]
2272    fn test_accessor_go() {
2273        let r = make_resolver();
2274        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
2275    }
2276
2277    #[test]
2278    fn test_accessor_go_initialism_fields() {
2279        let mut fields = std::collections::HashMap::new();
2280        fields.insert("content".to_string(), "html".to_string());
2281        fields.insert("link_url".to_string(), "links.url".to_string());
2282        let r = FieldResolver::new(
2283            &fields,
2284            &HashSet::new(),
2285            &HashSet::new(),
2286            &HashSet::new(),
2287            &HashSet::new(),
2288        );
2289        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
2290        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
2291        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
2292        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
2293        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
2294        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
2295        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
2296        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
2297    }
2298
2299    #[test]
2300    fn test_accessor_typescript() {
2301        let r = make_resolver();
2302        assert_eq!(
2303            r.accessor("title", "typescript", "result"),
2304            "result.metadata.document.title"
2305        );
2306    }
2307
2308    #[test]
2309    fn test_accessor_typescript_snake_to_camel() {
2310        let r = make_resolver();
2311        assert_eq!(
2312            r.accessor("og", "typescript", "result"),
2313            "result.metadata.document.openGraph"
2314        );
2315        assert_eq!(
2316            r.accessor("twitter", "typescript", "result"),
2317            "result.metadata.document.twitterCard"
2318        );
2319        assert_eq!(
2320            r.accessor("canonical", "typescript", "result"),
2321            "result.metadata.document.canonicalUrl"
2322        );
2323    }
2324
2325    #[test]
2326    fn test_accessor_typescript_map_snake_to_camel() {
2327        let r = make_resolver();
2328        assert_eq!(
2329            r.accessor("og_tag", "typescript", "result"),
2330            "result.metadata.openGraphTags[\"og_title\"]"
2331        );
2332    }
2333
2334    #[test]
2335    fn test_accessor_typescript_numeric_index_is_unquoted() {
2336        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
2337        // must emit numeric bracket access (`[0]`) not string-keyed access
2338        // (`["0"]`), which would return undefined on arrays.
2339        let mut fields = HashMap::new();
2340        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
2341        let r = FieldResolver::new(
2342            &fields,
2343            &HashSet::new(),
2344            &HashSet::new(),
2345            &HashSet::new(),
2346            &HashSet::new(),
2347        );
2348        assert_eq!(
2349            r.accessor("first_score", "typescript", "result"),
2350            "result.results[0].relevanceScore"
2351        );
2352    }
2353
2354    #[test]
2355    fn test_accessor_node_alias() {
2356        let r = make_resolver();
2357        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
2358    }
2359
2360    #[test]
2361    fn test_accessor_wasm_camel_case() {
2362        let r = make_resolver();
2363        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
2364        assert_eq!(
2365            r.accessor("twitter", "wasm", "result"),
2366            "result.metadata.document.twitterCard"
2367        );
2368        assert_eq!(
2369            r.accessor("canonical", "wasm", "result"),
2370            "result.metadata.document.canonicalUrl"
2371        );
2372    }
2373
2374    #[test]
2375    fn test_accessor_wasm_map_access() {
2376        let r = make_resolver();
2377        assert_eq!(
2378            r.accessor("og_tag", "wasm", "result"),
2379            "result.metadata.openGraphTags.get(\"og_title\")"
2380        );
2381    }
2382
2383    #[test]
2384    fn test_accessor_java() {
2385        let r = make_resolver();
2386        assert_eq!(
2387            r.accessor("title", "java", "result"),
2388            "result.metadata().document().title()"
2389        );
2390    }
2391
2392    #[test]
2393    fn test_accessor_kotlin_uses_kotlin_collection_idioms() {
2394        let mut fields = HashMap::new();
2395        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2396        fields.insert("node_count".to_string(), "nodes.length".to_string());
2397        let mut arrays = HashSet::new();
2398        arrays.insert("nodes".to_string());
2399        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &arrays, &HashSet::new());
2400        assert_eq!(
2401            r.accessor("first_node_name", "kotlin", "result"),
2402            "result.nodes().first().name()"
2403        );
2404        assert_eq!(r.accessor("node_count", "kotlin", "result"), "result.nodes().size");
2405    }
2406
2407    #[test]
2408    fn test_accessor_kotlin_uses_safe_calls_for_optional_prefixes() {
2409        let r = make_resolver_with_doc_optional();
2410        assert_eq!(
2411            r.accessor("title", "kotlin", "result"),
2412            "result.metadata().document()?.title()"
2413        );
2414    }
2415
2416    #[test]
2417    fn test_accessor_kotlin_uses_safe_calls_for_optional_arrays_and_maps() {
2418        let mut fields = HashMap::new();
2419        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2420        fields.insert("tag".to_string(), "tags[name]".to_string());
2421        let mut optional = HashSet::new();
2422        optional.insert("nodes".to_string());
2423        optional.insert("tags".to_string());
2424        let mut arrays = HashSet::new();
2425        arrays.insert("nodes".to_string());
2426        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2427        assert_eq!(
2428            r.accessor("first_node_name", "kotlin", "result"),
2429            "result.nodes()?.first()?.name()"
2430        );
2431        assert_eq!(r.accessor("tag", "kotlin", "result"), "result.tags()?.get(\"name\")");
2432    }
2433
2434    /// Regression: optional-field keys with explicit `[0]` indices (e.g.
2435    /// `"choices[0].message.tool_calls"`) were not matched by
2436    /// `render_kotlin_with_optionals` because `path_so_far` omitted the `[0]`
2437    /// suffix after traversing an ArrayField segment. Fix: append `"[0]"` to
2438    /// `path_so_far` after each ArrayField, mirroring the Rust renderer.
2439    #[test]
2440    fn test_accessor_kotlin_optional_field_after_indexed_array() {
2441        // "choices[0].message.tool_calls" is optional; the path is accessed as
2442        // choices[0].message.tool_calls[0].function.name.
2443        let mut fields = HashMap::new();
2444        fields.insert(
2445            "tool_call_name".to_string(),
2446            "choices[0].message.tool_calls[0].function.name".to_string(),
2447        );
2448        let mut optional = HashSet::new();
2449        optional.insert("choices[0].message.tool_calls".to_string());
2450        let mut arrays = HashSet::new();
2451        arrays.insert("choices".to_string());
2452        arrays.insert("choices[0].message.tool_calls".to_string());
2453        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2454        let expr = r.accessor("tool_call_name", "kotlin", "result");
2455        // toolCalls() is optional so it must use `?.` before `.first()`.
2456        assert!(
2457            expr.contains("toolCalls()?.first()"),
2458            "expected toolCalls()?.first() for optional list, got: {expr}"
2459        );
2460    }
2461
2462    #[test]
2463    fn test_accessor_csharp() {
2464        let r = make_resolver();
2465        assert_eq!(
2466            r.accessor("title", "csharp", "result"),
2467            "result.Metadata.Document.Title"
2468        );
2469    }
2470
2471    #[test]
2472    fn test_accessor_php() {
2473        let r = make_resolver();
2474        assert_eq!(
2475            r.accessor("title", "php", "$result"),
2476            "$result->metadata->document->title"
2477        );
2478    }
2479
2480    #[test]
2481    fn test_accessor_r() {
2482        let r = make_resolver();
2483        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
2484    }
2485
2486    #[test]
2487    fn test_accessor_c() {
2488        let r = make_resolver();
2489        assert_eq!(
2490            r.accessor("title", "c", "result"),
2491            "result_metadata_document_title(result)"
2492        );
2493    }
2494
2495    #[test]
2496    fn test_rust_unwrap_binding() {
2497        let r = make_resolver();
2498        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
2499        assert_eq!(var, "metadata_document_title");
2500        // Optional scalar fields are unwrapped via Display (`to_string()`) so enum
2501        // types like `FinishReason` render their serde-style string form.
2502        assert!(binding.contains("as_ref().map(|v| v.to_string()).unwrap_or_default()"));
2503    }
2504
2505    #[test]
2506    fn test_rust_unwrap_binding_non_optional() {
2507        let r = make_resolver();
2508        assert!(r.rust_unwrap_binding("content", "result").is_none());
2509    }
2510
2511    #[test]
2512    fn test_rust_unwrap_binding_collapses_double_underscore() {
2513        // When an alias resolves to a path with `[]` (e.g. `json_ld.name` →
2514        // `json_ld[].name`), the naive replace previously yielded `json_ld__name`,
2515        // which trips Rust's non_snake_case lint under -D warnings. The local
2516        // binding name must collapse consecutive underscores into one.
2517        let mut aliases = HashMap::new();
2518        aliases.insert("json_ld.name".to_string(), "json_ld[].name".to_string());
2519        let mut optional = HashSet::new();
2520        optional.insert("json_ld[].name".to_string());
2521        let mut array = HashSet::new();
2522        array.insert("json_ld".to_string());
2523        let result_fields = HashSet::new();
2524        let method_calls = HashSet::new();
2525        let r = FieldResolver::new(&aliases, &optional, &result_fields, &array, &method_calls);
2526        let (_binding, var) = r.rust_unwrap_binding("json_ld.name", "result").unwrap();
2527        assert_eq!(var, "json_ld_name");
2528    }
2529
2530    #[test]
2531    fn test_direct_field_no_alias() {
2532        let r = make_resolver();
2533        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2534        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
2535    }
2536
2537    #[test]
2538    fn test_accessor_rust_with_optionals() {
2539        let r = make_resolver_with_doc_optional();
2540        assert_eq!(
2541            r.accessor("title", "rust", "result"),
2542            "result.metadata.document.as_ref().unwrap().title"
2543        );
2544    }
2545
2546    #[test]
2547    fn test_accessor_csharp_with_optionals() {
2548        let r = make_resolver_with_doc_optional();
2549        assert_eq!(
2550            r.accessor("title", "csharp", "result"),
2551            "result.Metadata.Document!.Title"
2552        );
2553    }
2554
2555    #[test]
2556    fn test_accessor_rust_non_optional_field() {
2557        let r = make_resolver();
2558        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2559    }
2560
2561    #[test]
2562    fn test_accessor_csharp_non_optional_field() {
2563        let r = make_resolver();
2564        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
2565    }
2566
2567    #[test]
2568    fn test_accessor_rust_method_call() {
2569        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
2570        let mut fields = HashMap::new();
2571        fields.insert(
2572            "excel_sheet_count".to_string(),
2573            "metadata.format.excel.sheet_count".to_string(),
2574        );
2575        let mut optional = HashSet::new();
2576        optional.insert("metadata.format".to_string());
2577        optional.insert("metadata.format.excel".to_string());
2578        let mut method_calls = HashSet::new();
2579        method_calls.insert("metadata.format.excel".to_string());
2580        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
2581        assert_eq!(
2582            r.accessor("excel_sheet_count", "rust", "result"),
2583            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
2584        );
2585    }
2586
2587    // ---------------------------------------------------------------------------
2588    // PHP getter-method tests (ext-php-rs 0.15.x `#[php(getter)]` vs `#[php(prop)]`)
2589    // ---------------------------------------------------------------------------
2590
2591    fn make_php_getter_resolver() -> FieldResolver {
2592        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2593        getters.insert(
2594            "Root".to_string(),
2595            ["metadata".to_string(), "links".to_string()].into_iter().collect(),
2596        );
2597        let map = PhpGetterMap {
2598            getters,
2599            field_types: HashMap::new(),
2600            root_type: Some("Root".to_string()),
2601            all_fields: HashMap::new(),
2602        };
2603        FieldResolver::new_with_php_getters(
2604            &HashMap::new(),
2605            &HashSet::new(),
2606            &HashSet::new(),
2607            &HashSet::new(),
2608            &HashSet::new(),
2609            &HashMap::new(),
2610            map,
2611        )
2612    }
2613
2614    #[test]
2615    fn render_php_uses_getter_method_for_non_scalar_field() {
2616        let r = make_php_getter_resolver();
2617        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->getMetadata()");
2618    }
2619
2620    #[test]
2621    fn render_php_uses_property_for_scalar_field() {
2622        let r = make_php_getter_resolver();
2623        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2624    }
2625
2626    #[test]
2627    fn render_php_nested_non_scalar_uses_getter_then_property() {
2628        let mut fields = HashMap::new();
2629        fields.insert("title".to_string(), "metadata.title".to_string());
2630        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2631        getters.insert("Root".to_string(), ["metadata".to_string()].into_iter().collect());
2632        // No entry for Metadata.title → scalar by default.
2633        getters.insert("Metadata".to_string(), HashSet::new());
2634        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2635        field_types.insert(
2636            "Root".to_string(),
2637            [("metadata".to_string(), "Metadata".to_string())].into_iter().collect(),
2638        );
2639        let map = PhpGetterMap {
2640            getters,
2641            field_types,
2642            root_type: Some("Root".to_string()),
2643            all_fields: HashMap::new(),
2644        };
2645        let r = FieldResolver::new_with_php_getters(
2646            &fields,
2647            &HashSet::new(),
2648            &HashSet::new(),
2649            &HashSet::new(),
2650            &HashSet::new(),
2651            &HashMap::new(),
2652            map,
2653        );
2654        // `metadata` → `->getMetadata()`, then `title` (scalar on returned object) → `->title`
2655        assert_eq!(r.accessor("title", "php", "$result"), "$result->getMetadata()->title");
2656    }
2657
2658    #[test]
2659    fn render_php_array_field_uses_getter_when_non_scalar() {
2660        let mut fields = HashMap::new();
2661        fields.insert("first_link".to_string(), "links[0]".to_string());
2662        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2663        getters.insert("Root".to_string(), ["links".to_string()].into_iter().collect());
2664        let map = PhpGetterMap {
2665            getters,
2666            field_types: HashMap::new(),
2667            root_type: Some("Root".to_string()),
2668            all_fields: HashMap::new(),
2669        };
2670        let r = FieldResolver::new_with_php_getters(
2671            &fields,
2672            &HashSet::new(),
2673            &HashSet::new(),
2674            &HashSet::new(),
2675            &HashSet::new(),
2676            &HashMap::new(),
2677            map,
2678        );
2679        assert_eq!(r.accessor("first_link", "php", "$result"), "$result->getLinks()[0]");
2680    }
2681
2682    #[test]
2683    fn render_php_falls_back_to_property_when_getter_fields_empty() {
2684        // With empty php_getter_map the resolver uses the plain `render_php` path,
2685        // which emits `->camelCase` for every field regardless of scalar-ness.
2686        let r = FieldResolver::new(
2687            &HashMap::new(),
2688            &HashSet::new(),
2689            &HashSet::new(),
2690            &HashSet::new(),
2691            &HashSet::new(),
2692        );
2693        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2694        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->metadata");
2695    }
2696
2697    // Regression: bare-name HashSet classification produced false getters when two
2698    // types shared a field name with different scalarness (kreuzcrawl `content`
2699    // collision between CrawlConfig.content: ContentConfig and MarkdownResult.content: String).
2700    #[test]
2701    fn render_php_with_getters_distinguishes_same_field_name_on_different_types() {
2702        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2703        // A.content is non-scalar.
2704        getters.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2705        // B.content is scalar — explicit empty set.
2706        getters.insert("B".to_string(), HashSet::new());
2707        // Both A and B declare a "content" field — needed so the per-type
2708        // classification is consulted (not fallback bare-name union).
2709        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2710        all_fields.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2711        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2712        let map_a = PhpGetterMap {
2713            getters: getters.clone(),
2714            field_types: HashMap::new(),
2715            root_type: Some("A".to_string()),
2716            all_fields: all_fields.clone(),
2717        };
2718        let map_b = PhpGetterMap {
2719            getters,
2720            field_types: HashMap::new(),
2721            root_type: Some("B".to_string()),
2722            all_fields,
2723        };
2724        let r_a = FieldResolver::new_with_php_getters(
2725            &HashMap::new(),
2726            &HashSet::new(),
2727            &HashSet::new(),
2728            &HashSet::new(),
2729            &HashSet::new(),
2730            &HashMap::new(),
2731            map_a,
2732        );
2733        let r_b = FieldResolver::new_with_php_getters(
2734            &HashMap::new(),
2735            &HashSet::new(),
2736            &HashSet::new(),
2737            &HashSet::new(),
2738            &HashSet::new(),
2739            &HashMap::new(),
2740            map_b,
2741        );
2742        assert_eq!(r_a.accessor("content", "php", "$a"), "$a->getContent()");
2743        assert_eq!(r_b.accessor("content", "php", "$b"), "$b->content");
2744    }
2745
2746    // Regression: the chain renderer must advance current_type through the IR's
2747    // nested-type graph so a scalar field on a nested type is not falsely
2748    // classified as needing a getter because some other type uses the same name.
2749    #[test]
2750    fn render_php_with_getters_chains_through_correct_type() {
2751        let mut fields = HashMap::new();
2752        fields.insert("nested_content".to_string(), "inner.content".to_string());
2753        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2754        // Outer.inner is non-scalar (struct B).
2755        getters.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2756        // B.content is scalar.
2757        getters.insert("B".to_string(), HashSet::new());
2758        // Decoy: another type with non-scalar `content` field — used to verify
2759        // the legacy bare-name union would have produced the wrong answer.
2760        getters.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2761        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2762        field_types.insert(
2763            "Outer".to_string(),
2764            [("inner".to_string(), "B".to_string())].into_iter().collect(),
2765        );
2766        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2767        all_fields.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2768        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2769        all_fields.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2770        let map = PhpGetterMap {
2771            getters,
2772            field_types,
2773            root_type: Some("Outer".to_string()),
2774            all_fields,
2775        };
2776        let r = FieldResolver::new_with_php_getters(
2777            &fields,
2778            &HashSet::new(),
2779            &HashSet::new(),
2780            &HashSet::new(),
2781            &HashSet::new(),
2782            &HashMap::new(),
2783            map,
2784        );
2785        assert_eq!(
2786            r.accessor("nested_content", "php", "$result"),
2787            "$result->getInner()->content"
2788        );
2789    }
2790
2791    // ---------------------------------------------------------------------------
2792    // Namespace-prefix stripping tests
2793    // ---------------------------------------------------------------------------
2794
2795    fn make_resolver_with_result_fields(result_fields: &[&str]) -> FieldResolver {
2796        let rf: HashSet<String> = result_fields.iter().map(|s| s.to_string()).collect();
2797        FieldResolver::new(&HashMap::new(), &HashSet::new(), &rf, &HashSet::new(), &HashSet::new())
2798    }
2799
2800    /// `browser.browser_used` — `browser` is a virtual namespace prefix, actual
2801    /// field is `browser_used` which IS in result_fields.
2802    #[test]
2803    fn is_valid_for_result_accepts_virtual_namespace_prefix() {
2804        let r = make_resolver_with_result_fields(&["browser_used", "js_render_hint", "status_code"]);
2805        assert!(
2806            r.is_valid_for_result("browser.browser_used"),
2807            "browser.browser_used should be valid via namespace-prefix stripping"
2808        );
2809        assert!(
2810            r.is_valid_for_result("browser.js_render_hint"),
2811            "browser.js_render_hint should be valid via namespace-prefix stripping"
2812        );
2813    }
2814
2815    /// `interaction.action_results[0].action_type` — `interaction` is a virtual
2816    /// namespace prefix, `action_results` IS in result_fields.
2817    #[test]
2818    fn is_valid_for_result_accepts_namespace_prefix_before_array_field() {
2819        let r = make_resolver_with_result_fields(&["action_results", "final_html", "final_url"]);
2820        assert!(
2821            r.is_valid_for_result("interaction.action_results[0].action_type"),
2822            "interaction. prefix should be stripped so action_results is recognised"
2823        );
2824    }
2825
2826    /// Fields that genuinely don't exist should still be rejected.
2827    #[test]
2828    fn is_valid_for_result_rejects_unknown_field_even_after_namespace_strip() {
2829        let r = make_resolver_with_result_fields(&["pages", "final_url"]);
2830        assert!(
2831            !r.is_valid_for_result("browser.browser_used"),
2832            "browser_used is not in result_fields so should be rejected"
2833        );
2834        assert!(
2835            !r.is_valid_for_result("ns.unknown_field"),
2836            "unknown_field is not in result_fields so should be rejected"
2837        );
2838    }
2839
2840    /// Accessor for `browser.browser_used` should produce the stripped path
2841    /// (i.e. `result.browser_used` for Python, not `result.browser.browser_used`).
2842    #[test]
2843    fn accessor_strips_namespace_prefix_for_python() {
2844        let r = make_resolver_with_result_fields(&["browser_used", "js_render_hint"]);
2845        assert_eq!(
2846            r.accessor("browser.browser_used", "python", "result"),
2847            "result.browser_used"
2848        );
2849        assert_eq!(
2850            r.accessor("browser.js_render_hint", "python", "result"),
2851            "result.js_render_hint"
2852        );
2853    }
2854
2855    /// Accessor for `browser.browser_used` should produce PascalCase path for C#.
2856    #[test]
2857    fn accessor_strips_namespace_prefix_for_csharp() {
2858        let r = make_resolver_with_result_fields(&["browser_used"]);
2859        assert_eq!(
2860            r.accessor("browser.browser_used", "csharp", "result"),
2861            "result.BrowserUsed"
2862        );
2863    }
2864
2865    /// Accessor for `interaction.action_results[0].action_type` — strips `interaction.`
2866    /// prefix and resolves the remaining path.
2867    #[test]
2868    fn accessor_strips_namespace_prefix_for_indexed_array_field() {
2869        let r = make_resolver_with_result_fields(&["action_results", "final_html", "final_url"]);
2870        // Python: result.action_results[0].action_type
2871        assert_eq!(
2872            r.accessor("interaction.action_results[0].action_type", "python", "result"),
2873            "result.action_results[0].action_type"
2874        );
2875        // TypeScript: result.actionResults[0].actionType
2876        assert_eq!(
2877            r.accessor("interaction.action_results[0].action_type", "typescript", "result"),
2878            "result.actionResults[0].actionType"
2879        );
2880    }
2881
2882    /// When `result_fields` is empty, namespace stripping is disabled and every
2883    /// path is accepted (the permissive default).
2884    #[test]
2885    fn is_valid_for_result_is_permissive_when_result_fields_empty() {
2886        let r = make_resolver_with_result_fields(&[]);
2887        assert!(r.is_valid_for_result("browser.browser_used"));
2888        assert!(r.is_valid_for_result("anything.at.all"));
2889    }
2890
2891    /// A real two-segment path like `metadata.title` where `metadata` IS a
2892    /// known result field must NOT be stripped — the full path resolves correctly.
2893    #[test]
2894    fn accessor_does_not_strip_real_first_segment() {
2895        let r = make_resolver_with_result_fields(&["metadata", "status_code"]);
2896        // `metadata` is a real result field; should not be stripped.
2897        assert_eq!(
2898            r.accessor("metadata.title", "python", "result"),
2899            "result.metadata.title"
2900        );
2901    }
2902}