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