Skip to main content

alef_e2e/
field_access.rs

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