Skip to main content

alef_e2e/
field_access.rs

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