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 Debug format instead of Display to handle types like FormatMetadata that don't impl to_string().
746            // This allows optional enum types to be converted to strings for comparison.
747            format!("let {local_var} = {accessor}.as_ref().map(|v| format!(\"{{:?}}\", v)).unwrap_or_default();")
748        };
749        Some((binding, local_var))
750    }
751}
752
753/// Strip all numeric indices from a path so `"choices[0].message.tool_calls"` →
754/// `"choices.message.tool_calls"`. Used by `is_optional` to match entries like
755/// `"choices.message.tool_calls"` in `optional_fields` when the caller supplies a
756/// path that includes a concrete index.
757fn strip_numeric_indices(path: &str) -> String {
758    let mut result = String::with_capacity(path.len());
759    let mut chars = path.chars().peekable();
760    while let Some(c) = chars.next() {
761        if c == '[' {
762            let mut key = String::new();
763            let mut closed = false;
764            for inner in chars.by_ref() {
765                if inner == ']' {
766                    closed = true;
767                    break;
768                }
769                key.push(inner);
770            }
771            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
772                // Numeric index — drop it entirely (including any trailing dot).
773            } else {
774                result.push('[');
775                result.push_str(&key);
776                if closed {
777                    result.push(']');
778                }
779            }
780        } else {
781            result.push(c);
782        }
783    }
784    // Collapse any double-dots introduced by dropping `[N].` sequences.
785    while result.contains("..") {
786        result = result.replace("..", ".");
787    }
788    if result.starts_with('.') {
789        result.remove(0);
790    }
791    result
792}
793
794fn normalize_numeric_indices(path: &str) -> String {
795    let mut result = String::with_capacity(path.len());
796    let mut chars = path.chars().peekable();
797    while let Some(c) = chars.next() {
798        if c == '[' {
799            let mut key = String::new();
800            let mut closed = false;
801            for inner in chars.by_ref() {
802                if inner == ']' {
803                    closed = true;
804                    break;
805                }
806                key.push(inner);
807            }
808            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
809                result.push_str("[0]");
810            } else {
811                result.push('[');
812                result.push_str(&key);
813                if closed {
814                    result.push(']');
815                }
816            }
817        } else {
818            result.push(c);
819        }
820    }
821    result
822}
823
824fn parse_path(path: &str) -> Vec<PathSegment> {
825    let mut segments = Vec::new();
826    for part in path.split('.') {
827        if part == "length" || part == "count" || part == "size" {
828            segments.push(PathSegment::Length);
829        } else if let Some(bracket_pos) = part.find('[') {
830            let name = part[..bracket_pos].to_string();
831            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
832            if key.is_empty() {
833                // `foo[]` — bare array bracket, index defaults to 0 (upgraded by inject_array_indexing).
834                segments.push(PathSegment::ArrayField { name, index: 0 });
835            } else if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
836                // `foo[N]` — user-typed explicit numeric index.
837                let index: usize = key.parse().unwrap_or(0);
838                segments.push(PathSegment::ArrayField { name, index });
839            } else {
840                // `foo[key]` — string-keyed map access.
841                segments.push(PathSegment::MapAccess { field: name, key });
842            }
843        } else {
844            segments.push(PathSegment::Field(part.to_string()));
845        }
846    }
847    segments
848}
849
850fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
851    match language {
852        "rust" => render_rust(segments, result_var),
853        "python" => render_dot_access(segments, result_var, "python"),
854        "typescript" | "node" => render_typescript(segments, result_var),
855        "wasm" => render_wasm(segments, result_var),
856        "go" => render_go(segments, result_var),
857        "java" => render_java(segments, result_var),
858        "kotlin" => render_kotlin(segments, result_var),
859        "kotlin_android" => render_kotlin_android(segments, result_var),
860        "csharp" => render_pascal_dot(segments, result_var),
861        "ruby" => render_dot_access(segments, result_var, "ruby"),
862        "php" => render_php(segments, result_var),
863        "elixir" => render_dot_access(segments, result_var, "elixir"),
864        "r" => render_r(segments, result_var),
865        "c" => render_c(segments, result_var),
866        "swift" => render_swift(segments, result_var),
867        "dart" => render_dart(segments, result_var),
868        _ => render_dot_access(segments, result_var, language),
869    }
870}
871
872/// Generate a Swift accessor expression.
873///
874/// Alef now emits first-class Swift structs (`public struct Foo: Codable { public let
875/// id: String }`) for most DTO types, where fields are properties — property access
876/// uses `.id` (no parens). The remaining typealias-to-opaque types (e.g. request
877/// types with Vec/Map/Named fields that aren't first-class candidates) are accessed
878/// via the swift-bridge-generated method-call syntax `.id()`, but in e2e tests these
879/// typealias types are method inputs / streaming outputs rather than parents for
880/// field-access chains, so property syntax works in practice. If a future e2e test
881/// asserts on a field-access chain rooted in an opaque type, a per-type
882/// `SwiftFirstClassMap` (analogous to `PhpGetterMap`) would be needed.
883fn render_swift(segments: &[PathSegment], result_var: &str) -> String {
884    let mut out = result_var.to_string();
885    for seg in segments {
886        match seg {
887            PathSegment::Field(f) => {
888                out.push('.');
889                out.push_str(&f.to_lower_camel_case());
890            }
891            PathSegment::ArrayField { name, index } => {
892                out.push('.');
893                out.push_str(&name.to_lower_camel_case());
894                out.push_str(&format!("[{index}]"));
895            }
896            PathSegment::MapAccess { field, key } => {
897                out.push('.');
898                out.push_str(&field.to_lower_camel_case());
899                if key.chars().all(|c| c.is_ascii_digit()) {
900                    out.push_str(&format!("[{key}]"));
901                } else {
902                    out.push_str(&format!("[\"{key}\"]"));
903                }
904            }
905            PathSegment::Length => {
906                out.push_str(".count");
907            }
908        }
909    }
910    out
911}
912
913/// Generate a Swift accessor expression with optional chaining.
914///
915/// When an intermediate field is in `optional_fields`, a `?` is inserted after the
916/// `()` call on that segment so the next access uses `?.`. This prevents compile
917/// errors when accessing members through an `Optional<T>` in Swift.
918///
919/// Example: for `metadata.format.excel.sheet_count` where `metadata.format` and
920/// `metadata.format.excel` are optional, the result is:
921/// `result.metadata().format()?.excel()?.sheet_count()`
922fn render_swift_with_optionals(
923    segments: &[PathSegment],
924    result_var: &str,
925    optional_fields: &HashSet<String>,
926) -> String {
927    let mut out = result_var.to_string();
928    let mut path_so_far = String::new();
929    let total = segments.len();
930    for (i, seg) in segments.iter().enumerate() {
931        let is_leaf = i == total - 1;
932        match seg {
933            PathSegment::Field(f) => {
934                if !path_so_far.is_empty() {
935                    path_so_far.push('.');
936                }
937                path_so_far.push_str(f);
938                out.push('.');
939                // Swift bindings always use lowerCamelCase for both first-class
940                // `public let` properties and swift-bridge accessor methods.
941                out.push_str(&f.to_lower_camel_case());
942                // First-class Swift struct fields are properties (no parens).
943                // Insert `?` after the property name for non-leaf optional fields so the
944                // next member access becomes `?.`.
945                if !is_leaf && optional_fields.contains(&path_so_far) {
946                    out.push('?');
947                }
948            }
949            PathSegment::ArrayField { name, index } => {
950                if !path_so_far.is_empty() {
951                    path_so_far.push('.');
952                }
953                path_so_far.push_str(name);
954                let is_optional = optional_fields.contains(&path_so_far);
955                out.push('.');
956                out.push_str(&name.to_lower_camel_case());
957                if is_optional {
958                    // Optional<[T]>: unwrap before indexing.
959                    out.push_str(&format!("?[{index}]"));
960                } else {
961                    out.push_str(&format!("[{index}]"));
962                }
963                path_so_far.push_str("[0]");
964                let _ = is_leaf;
965            }
966            PathSegment::MapAccess { field, key } => {
967                if !path_so_far.is_empty() {
968                    path_so_far.push('.');
969                }
970                path_so_far.push_str(field);
971                out.push('.');
972                out.push_str(&field.to_lower_camel_case());
973                if key.chars().all(|c| c.is_ascii_digit()) {
974                    out.push_str(&format!("[{key}]"));
975                } else {
976                    out.push_str(&format!("[\"{key}\"]"));
977                }
978            }
979            PathSegment::Length => {
980                out.push_str(".count");
981            }
982        }
983    }
984    out
985}
986
987/// Like `render_swift_with_optionals` but dispatches per-segment between
988/// property access (first-class Codable struct) and method-call access
989/// (typealias-to-opaque RustBridge class). Uses the `SwiftFirstClassMap` to
990/// track the current type as the path advances.
991fn render_swift_with_first_class_map(
992    segments: &[PathSegment],
993    result_var: &str,
994    optional_fields: &HashSet<String>,
995    map: &SwiftFirstClassMap,
996) -> String {
997    let mut out = result_var.to_string();
998    let mut path_so_far = String::new();
999    let mut current_type: Option<String> = map.root_type.clone();
1000    // Once a chain crosses an `ArrayField` segment, every subsequent segment
1001    // operates on an element pulled from a `RustVec<T>` — and `RustVec[i]`
1002    // yields the OPAQUE `RustBridge.T` (whose fields are swift-bridge methods),
1003    // never the first-class Codable Swift struct `T`. swift-bridge generates
1004    // `RustVec` as a thin wrapper around the Rust vector, not as a converter
1005    // to the binding's first-class struct. Pin opaque (method-call) syntax
1006    // after the first index step so paths like `data[0].id` emit `.id()` even
1007    // when the Codable `Model` first-class struct also exists.
1008    let mut via_rust_vec = false;
1009    let total = segments.len();
1010    for (i, seg) in segments.iter().enumerate() {
1011        let is_leaf = i == total - 1;
1012        let property_syntax = !via_rust_vec && map.is_first_class(current_type.as_deref());
1013        match seg {
1014            PathSegment::Field(f) => {
1015                if !path_so_far.is_empty() {
1016                    path_so_far.push('.');
1017                }
1018                path_so_far.push_str(f);
1019                out.push('.');
1020                // Swift bindings (both first-class `public let` props and
1021                // swift-bridge method names) always use lowerCamelCase.
1022                out.push_str(&f.to_lower_camel_case());
1023                if !property_syntax {
1024                    out.push_str("()");
1025                }
1026                if !is_leaf && optional_fields.contains(&path_so_far) {
1027                    out.push('?');
1028                }
1029                current_type = map.advance(current_type.as_deref(), f);
1030            }
1031            PathSegment::ArrayField { name, index } => {
1032                if !path_so_far.is_empty() {
1033                    path_so_far.push('.');
1034                }
1035                path_so_far.push_str(name);
1036                let is_optional = optional_fields.contains(&path_so_far);
1037                out.push('.');
1038                out.push_str(&name.to_lower_camel_case());
1039                let access = if property_syntax { "" } else { "()" };
1040                if is_optional {
1041                    out.push_str(&format!("{access}?[{index}]"));
1042                } else {
1043                    out.push_str(&format!("{access}[{index}]"));
1044                }
1045                path_so_far.push_str("[0]");
1046                // Indexing into a Vec<Named> yields a Named element — advance current_type.
1047                // Only pin opaque syntax when the array field was itself emitted in
1048                // method-call mode (i.e. it's a RustVec accessor). When the owning
1049                // type is first-class, the array IS a Swift `[T]` and indexing yields
1050                // the first-class `T` directly (also a Codable struct → property access).
1051                current_type = map.advance(current_type.as_deref(), name);
1052                if !property_syntax {
1053                    via_rust_vec = true;
1054                }
1055            }
1056            PathSegment::MapAccess { field, key } => {
1057                if !path_so_far.is_empty() {
1058                    path_so_far.push('.');
1059                }
1060                path_so_far.push_str(field);
1061                out.push('.');
1062                out.push_str(&field.to_lower_camel_case());
1063                let access = if property_syntax { "" } else { "()" };
1064                if key.chars().all(|c| c.is_ascii_digit()) {
1065                    out.push_str(&format!("{access}[{key}]"));
1066                } else {
1067                    out.push_str(&format!("{access}[\"{key}\"]"));
1068                }
1069                current_type = map.advance(current_type.as_deref(), field);
1070            }
1071            PathSegment::Length => {
1072                out.push_str(".count");
1073            }
1074        }
1075    }
1076    out
1077}
1078
1079fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
1080    let mut out = result_var.to_string();
1081    for seg in segments {
1082        match seg {
1083            PathSegment::Field(f) => {
1084                out.push('.');
1085                out.push_str(&f.to_snake_case());
1086            }
1087            PathSegment::ArrayField { name, index } => {
1088                out.push('.');
1089                out.push_str(&name.to_snake_case());
1090                out.push_str(&format!("[{index}]"));
1091            }
1092            PathSegment::MapAccess { field, key } => {
1093                out.push('.');
1094                out.push_str(&field.to_snake_case());
1095                if key.chars().all(|c| c.is_ascii_digit()) {
1096                    out.push_str(&format!("[{key}]"));
1097                } else {
1098                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
1099                }
1100            }
1101            PathSegment::Length => {
1102                out.push_str(".len()");
1103            }
1104        }
1105    }
1106    out
1107}
1108
1109fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
1110    let mut out = result_var.to_string();
1111    for seg in segments {
1112        match seg {
1113            PathSegment::Field(f) => {
1114                out.push('.');
1115                out.push_str(f);
1116            }
1117            PathSegment::ArrayField { name, index } => {
1118                if language == "elixir" {
1119                    let current = std::mem::take(&mut out);
1120                    out = format!("Enum.at({current}.{name}, {index})");
1121                } else {
1122                    out.push('.');
1123                    out.push_str(name);
1124                    out.push_str(&format!("[{index}]"));
1125                }
1126            }
1127            PathSegment::MapAccess { field, key } => {
1128                let is_numeric = key.chars().all(|c| c.is_ascii_digit());
1129                if is_numeric && language == "elixir" {
1130                    let current = std::mem::take(&mut out);
1131                    out = format!("Enum.at({current}.{field}, {key})");
1132                } else {
1133                    out.push('.');
1134                    out.push_str(field);
1135                    if is_numeric {
1136                        let idx: usize = key.parse().unwrap_or(0);
1137                        out.push_str(&format!("[{idx}]"));
1138                    } else if language == "elixir" || language == "ruby" {
1139                        // Ruby/Elixir hashes use `["key"]` bracket access (Ruby's Hash has
1140                        // no `get` method; Elixir maps use bracket access too).
1141                        out.push_str(&format!("[\"{key}\"]"));
1142                    } else {
1143                        out.push_str(&format!(".get(\"{key}\")"));
1144                    }
1145                }
1146            }
1147            PathSegment::Length => match language {
1148                "ruby" => out.push_str(".length"),
1149                "elixir" => {
1150                    let current = std::mem::take(&mut out);
1151                    out = format!("length({current})");
1152                }
1153                "gleam" => {
1154                    let current = std::mem::take(&mut out);
1155                    out = format!("list.length({current})");
1156                }
1157                _ => {
1158                    let current = std::mem::take(&mut out);
1159                    out = format!("len({current})");
1160                }
1161            },
1162        }
1163    }
1164    out
1165}
1166
1167fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
1168    let mut out = result_var.to_string();
1169    for seg in segments {
1170        match seg {
1171            PathSegment::Field(f) => {
1172                out.push('.');
1173                out.push_str(&f.to_lower_camel_case());
1174            }
1175            PathSegment::ArrayField { name, index } => {
1176                out.push('.');
1177                out.push_str(&name.to_lower_camel_case());
1178                out.push_str(&format!("[{index}]"));
1179            }
1180            PathSegment::MapAccess { field, key } => {
1181                out.push('.');
1182                out.push_str(&field.to_lower_camel_case());
1183                // Numeric (digit-only) keys index into arrays as integers, not as
1184                // string-keyed object properties; emit `[0]` not `["0"]`.
1185                if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
1186                    out.push_str(&format!("[{key}]"));
1187                } else {
1188                    out.push_str(&format!("[\"{key}\"]"));
1189                }
1190            }
1191            PathSegment::Length => {
1192                out.push_str(".length");
1193            }
1194        }
1195    }
1196    out
1197}
1198
1199fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
1200    let mut out = result_var.to_string();
1201    for seg in segments {
1202        match seg {
1203            PathSegment::Field(f) => {
1204                out.push('.');
1205                out.push_str(&f.to_lower_camel_case());
1206            }
1207            PathSegment::ArrayField { name, index } => {
1208                out.push('.');
1209                out.push_str(&name.to_lower_camel_case());
1210                out.push_str(&format!("[{index}]"));
1211            }
1212            PathSegment::MapAccess { field, key } => {
1213                out.push('.');
1214                out.push_str(&field.to_lower_camel_case());
1215                out.push_str(&format!(".get(\"{key}\")"));
1216            }
1217            PathSegment::Length => {
1218                out.push_str(".length");
1219            }
1220        }
1221    }
1222    out
1223}
1224
1225fn render_go(segments: &[PathSegment], result_var: &str) -> String {
1226    let mut out = result_var.to_string();
1227    for seg in segments {
1228        match seg {
1229            PathSegment::Field(f) => {
1230                out.push('.');
1231                out.push_str(&to_go_name(f));
1232            }
1233            PathSegment::ArrayField { name, index } => {
1234                out.push('.');
1235                out.push_str(&to_go_name(name));
1236                out.push_str(&format!("[{index}]"));
1237            }
1238            PathSegment::MapAccess { field, key } => {
1239                out.push('.');
1240                out.push_str(&to_go_name(field));
1241                if key.chars().all(|c| c.is_ascii_digit()) {
1242                    out.push_str(&format!("[{key}]"));
1243                } else {
1244                    out.push_str(&format!("[\"{key}\"]"));
1245                }
1246            }
1247            PathSegment::Length => {
1248                let current = std::mem::take(&mut out);
1249                out = format!("len({current})");
1250            }
1251        }
1252    }
1253    out
1254}
1255
1256fn render_java(segments: &[PathSegment], result_var: &str) -> String {
1257    let mut out = result_var.to_string();
1258    for seg in segments {
1259        match seg {
1260            PathSegment::Field(f) => {
1261                out.push('.');
1262                out.push_str(&f.to_lower_camel_case());
1263                out.push_str("()");
1264            }
1265            PathSegment::ArrayField { name, index } => {
1266                out.push('.');
1267                out.push_str(&name.to_lower_camel_case());
1268                out.push_str(&format!("().get({index})"));
1269            }
1270            PathSegment::MapAccess { field, key } => {
1271                out.push('.');
1272                out.push_str(&field.to_lower_camel_case());
1273                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1274                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1275                if is_numeric {
1276                    out.push_str(&format!("().get({key})"));
1277                } else {
1278                    out.push_str(&format!("().get(\"{key}\")"));
1279                }
1280            }
1281            PathSegment::Length => {
1282                out.push_str(".size()");
1283            }
1284        }
1285    }
1286    out
1287}
1288
1289/// Wrap a Kotlin getter name in backticks when it collides with a Kotlin hard keyword.
1290///
1291/// Hard keywords cannot be used as identifiers without escaping, so `result.object()`
1292/// is a syntax error; `` result.`object`() `` is the legal form.
1293fn kotlin_getter(name: &str) -> String {
1294    let camel = name.to_lower_camel_case();
1295    match camel.as_str() {
1296        "as" | "break" | "class" | "continue" | "do" | "else" | "false" | "for" | "fun" | "if" | "in" | "interface"
1297        | "is" | "null" | "object" | "package" | "return" | "super" | "this" | "throw" | "true" | "try"
1298        | "typealias" | "typeof" | "val" | "var" | "when" | "while" => format!("`{camel}`"),
1299        _ => camel,
1300    }
1301}
1302
1303fn render_kotlin(segments: &[PathSegment], result_var: &str) -> String {
1304    let mut out = result_var.to_string();
1305    for seg in segments {
1306        match seg {
1307            PathSegment::Field(f) => {
1308                out.push('.');
1309                out.push_str(&kotlin_getter(f));
1310                out.push_str("()");
1311            }
1312            PathSegment::ArrayField { name, index } => {
1313                out.push('.');
1314                out.push_str(&kotlin_getter(name));
1315                if *index == 0 {
1316                    out.push_str("().first()");
1317                } else {
1318                    out.push_str(&format!("().get({index})"));
1319                }
1320            }
1321            PathSegment::MapAccess { field, key } => {
1322                out.push('.');
1323                out.push_str(&kotlin_getter(field));
1324                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1325                if is_numeric {
1326                    out.push_str(&format!("().get({key})"));
1327                } else {
1328                    out.push_str(&format!("().get(\"{key}\")"));
1329                }
1330            }
1331            PathSegment::Length => {
1332                out.push_str(".size");
1333            }
1334        }
1335    }
1336    out
1337}
1338
1339fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1340    let mut out = result_var.to_string();
1341    let mut path_so_far = String::new();
1342    for (i, seg) in segments.iter().enumerate() {
1343        let is_leaf = i == segments.len() - 1;
1344        match seg {
1345            PathSegment::Field(f) => {
1346                if !path_so_far.is_empty() {
1347                    path_so_far.push('.');
1348                }
1349                path_so_far.push_str(f);
1350                out.push('.');
1351                out.push_str(&f.to_lower_camel_case());
1352                out.push_str("()");
1353                let _ = is_leaf;
1354                let _ = optional_fields;
1355            }
1356            PathSegment::ArrayField { name, index } => {
1357                if !path_so_far.is_empty() {
1358                    path_so_far.push('.');
1359                }
1360                path_so_far.push_str(name);
1361                out.push('.');
1362                out.push_str(&name.to_lower_camel_case());
1363                out.push_str(&format!("().get({index})"));
1364            }
1365            PathSegment::MapAccess { field, key } => {
1366                if !path_so_far.is_empty() {
1367                    path_so_far.push('.');
1368                }
1369                path_so_far.push_str(field);
1370                out.push('.');
1371                out.push_str(&field.to_lower_camel_case());
1372                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1373                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1374                if is_numeric {
1375                    out.push_str(&format!("().get({key})"));
1376                } else {
1377                    out.push_str(&format!("().get(\"{key}\")"));
1378                }
1379            }
1380            PathSegment::Length => {
1381                out.push_str(".size()");
1382            }
1383        }
1384    }
1385    out
1386}
1387
1388/// Kotlin variant of `render_java_with_optionals` using Kotlin idioms.
1389///
1390/// When the previous field in the chain is optional (nullable), uses `?.`
1391/// safe-call navigation for the next segment so the Kotlin compiler is
1392/// satisfied by the nullable receiver.
1393///
1394/// Nullability is **sticky**: once a `?.` safe-call has been emitted for any
1395/// segment, all subsequent segments also use `?.` because they operate on a
1396/// nullable receiver. A non-optional field after a `?.` call still returns
1397/// `T?` (because the whole chain can be null if any prefix was null).
1398///
1399/// Example: for `toolCalls[0].function.name` where `toolCalls` is optional:
1400/// `result.toolCalls()?.first()?.function()?.name()` — even though `function`
1401/// and `name` are themselves non-optional, they follow a `?.` chain.
1402fn render_kotlin_with_optionals(
1403    segments: &[PathSegment],
1404    result_var: &str,
1405    optional_fields: &HashSet<String>,
1406) -> String {
1407    let mut out = result_var.to_string();
1408    let mut path_so_far = String::new();
1409    // Track whether the previous segment returned a nullable type. Starts
1410    // false because `result_var` is always non-null.
1411    //
1412    // This flag is sticky: once set to true it stays true for the rest of
1413    // the chain because a `?.` call returns `T?` regardless of whether the
1414    // subsequent field itself is declared optional. All accesses on a
1415    // nullable receiver must also use `?.`.
1416    let mut prev_was_nullable = false;
1417    for seg in segments {
1418        let nav = if prev_was_nullable { "?." } else { "." };
1419        match seg {
1420            PathSegment::Field(f) => {
1421                if !path_so_far.is_empty() {
1422                    path_so_far.push('.');
1423                }
1424                path_so_far.push_str(f);
1425                // After this call, the receiver is nullable if the field is in
1426                // optional_fields (the Java @Nullable annotation makes the
1427                // return type T? in Kotlin) OR if the incoming receiver was
1428                // already nullable (sticky: `?.` call yields `T?`).
1429                let is_optional = optional_fields.contains(&path_so_far);
1430                out.push_str(nav);
1431                out.push_str(&kotlin_getter(f));
1432                out.push_str("()");
1433                prev_was_nullable = prev_was_nullable || is_optional;
1434            }
1435            PathSegment::ArrayField { name, index } => {
1436                if !path_so_far.is_empty() {
1437                    path_so_far.push('.');
1438                }
1439                path_so_far.push_str(name);
1440                let is_optional = optional_fields.contains(&path_so_far);
1441                out.push_str(nav);
1442                out.push_str(&kotlin_getter(name));
1443                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1444                if *index == 0 {
1445                    out.push_str(&format!("(){safe}.first()"));
1446                } else {
1447                    out.push_str(&format!("(){safe}.get({index})"));
1448                }
1449                // Record the "[0]" suffix so subsequent optional-field checks against
1450                // paths like "choices[0].message.tool_calls" continue to match when the
1451                // optional_fields set uses indexed keys (mirrors the Rust renderer).
1452                path_so_far.push_str("[0]");
1453                prev_was_nullable = prev_was_nullable || is_optional;
1454            }
1455            PathSegment::MapAccess { field, key } => {
1456                if !path_so_far.is_empty() {
1457                    path_so_far.push('.');
1458                }
1459                path_so_far.push_str(field);
1460                let is_optional = optional_fields.contains(&path_so_far);
1461                out.push_str(nav);
1462                out.push_str(&kotlin_getter(field));
1463                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1464                if is_numeric {
1465                    if prev_was_nullable || is_optional {
1466                        out.push_str(&format!("()?.get({key})"));
1467                    } else {
1468                        out.push_str(&format!("().get({key})"));
1469                    }
1470                } else if prev_was_nullable || is_optional {
1471                    out.push_str(&format!("()?.get(\"{key}\")"));
1472                } else {
1473                    out.push_str(&format!("().get(\"{key}\")"));
1474                }
1475                prev_was_nullable = prev_was_nullable || is_optional;
1476            }
1477            PathSegment::Length => {
1478                // .size is a Kotlin property, no () needed.
1479                // If the previous field was nullable, use ?.size
1480                let size_nav = if prev_was_nullable { "?" } else { "" };
1481                out.push_str(&format!("{size_nav}.size"));
1482                prev_was_nullable = false;
1483            }
1484        }
1485    }
1486    out
1487}
1488
1489/// kotlin_android variant of `render_kotlin_with_optionals`.
1490///
1491/// kotlin_android generates Kotlin data classes whose fields are Kotlin
1492/// **properties** (not Java-style getter methods). Every field segment must
1493/// therefore be accessed without parentheses: `result.choices.first().message.content`
1494/// rather than `result.choices().first().message().content()`.
1495///
1496/// The nullable-chain rules are identical to `render_kotlin_with_optionals`:
1497/// once a segment in the path is optional (`T?`) the remainder of the chain
1498/// uses `?.` safe-call syntax.
1499fn render_kotlin_android_with_optionals(
1500    segments: &[PathSegment],
1501    result_var: &str,
1502    optional_fields: &HashSet<String>,
1503) -> String {
1504    let mut out = result_var.to_string();
1505    let mut path_so_far = String::new();
1506    let mut prev_was_nullable = false;
1507    for seg in segments {
1508        let nav = if prev_was_nullable { "?." } else { "." };
1509        match seg {
1510            PathSegment::Field(f) => {
1511                if !path_so_far.is_empty() {
1512                    path_so_far.push('.');
1513                }
1514                path_so_far.push_str(f);
1515                let is_optional = optional_fields.contains(&path_so_far);
1516                out.push_str(nav);
1517                // Property access — no () suffix.
1518                out.push_str(&kotlin_getter(f));
1519                prev_was_nullable = prev_was_nullable || is_optional;
1520            }
1521            PathSegment::ArrayField { name, index } => {
1522                if !path_so_far.is_empty() {
1523                    path_so_far.push('.');
1524                }
1525                path_so_far.push_str(name);
1526                let is_optional = optional_fields.contains(&path_so_far);
1527                out.push_str(nav);
1528                // Property access — no () suffix on the collection itself.
1529                out.push_str(&kotlin_getter(name));
1530                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1531                if *index == 0 {
1532                    out.push_str(&format!("{safe}.first()"));
1533                } else {
1534                    out.push_str(&format!("{safe}.get({index})"));
1535                }
1536                path_so_far.push_str("[0]");
1537                prev_was_nullable = prev_was_nullable || is_optional;
1538            }
1539            PathSegment::MapAccess { field, key } => {
1540                if !path_so_far.is_empty() {
1541                    path_so_far.push('.');
1542                }
1543                path_so_far.push_str(field);
1544                let is_optional = optional_fields.contains(&path_so_far);
1545                out.push_str(nav);
1546                // Property access — no () suffix on the map field.
1547                out.push_str(&kotlin_getter(field));
1548                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1549                if is_numeric {
1550                    if prev_was_nullable || is_optional {
1551                        out.push_str(&format!("?.get({key})"));
1552                    } else {
1553                        out.push_str(&format!(".get({key})"));
1554                    }
1555                } else if prev_was_nullable || is_optional {
1556                    out.push_str(&format!("?.get(\"{key}\")"));
1557                } else {
1558                    out.push_str(&format!(".get(\"{key}\")"));
1559                }
1560                prev_was_nullable = prev_was_nullable || is_optional;
1561            }
1562            PathSegment::Length => {
1563                let size_nav = if prev_was_nullable { "?" } else { "" };
1564                out.push_str(&format!("{size_nav}.size"));
1565                prev_was_nullable = false;
1566            }
1567        }
1568    }
1569    out
1570}
1571
1572/// Non-optional variant of `render_kotlin_android_with_optionals`.
1573///
1574/// Used by `render_accessor` (the path without per-field optionality tracking).
1575fn render_kotlin_android(segments: &[PathSegment], result_var: &str) -> String {
1576    let mut out = result_var.to_string();
1577    for seg in segments {
1578        match seg {
1579            PathSegment::Field(f) => {
1580                out.push('.');
1581                out.push_str(&kotlin_getter(f));
1582                // No () — property access.
1583            }
1584            PathSegment::ArrayField { name, index } => {
1585                out.push('.');
1586                out.push_str(&kotlin_getter(name));
1587                if *index == 0 {
1588                    out.push_str(".first()");
1589                } else {
1590                    out.push_str(&format!(".get({index})"));
1591                }
1592            }
1593            PathSegment::MapAccess { field, key } => {
1594                out.push('.');
1595                out.push_str(&kotlin_getter(field));
1596                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1597                if is_numeric {
1598                    out.push_str(&format!(".get({key})"));
1599                } else {
1600                    out.push_str(&format!(".get(\"{key}\")"));
1601                }
1602            }
1603            PathSegment::Length => {
1604                out.push_str(".size");
1605            }
1606        }
1607    }
1608    out
1609}
1610
1611/// Rust accessor with Option unwrapping for intermediate fields.
1612///
1613/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
1614/// is appended after the field access to unwrap the `Option<T>`.
1615/// When a path is in `method_calls`, `()` is appended to make it a method call.
1616fn render_rust_with_optionals(
1617    segments: &[PathSegment],
1618    result_var: &str,
1619    optional_fields: &HashSet<String>,
1620    method_calls: &HashSet<String>,
1621) -> String {
1622    let mut out = result_var.to_string();
1623    let mut path_so_far = String::new();
1624    for (i, seg) in segments.iter().enumerate() {
1625        let is_leaf = i == segments.len() - 1;
1626        match seg {
1627            PathSegment::Field(f) => {
1628                if !path_so_far.is_empty() {
1629                    path_so_far.push('.');
1630                }
1631                path_so_far.push_str(f);
1632                out.push('.');
1633                out.push_str(&f.to_snake_case());
1634                let is_method = method_calls.contains(&path_so_far);
1635                if is_method {
1636                    out.push_str("()");
1637                    if !is_leaf && optional_fields.contains(&path_so_far) {
1638                        out.push_str(".as_ref().unwrap()");
1639                    }
1640                } else if !is_leaf && optional_fields.contains(&path_so_far) {
1641                    out.push_str(".as_ref().unwrap()");
1642                }
1643            }
1644            PathSegment::ArrayField { name, index } => {
1645                if !path_so_far.is_empty() {
1646                    path_so_far.push('.');
1647                }
1648                path_so_far.push_str(name);
1649                out.push('.');
1650                out.push_str(&name.to_snake_case());
1651                // Option<Vec<T>>: must unwrap the Option before indexing.
1652                // Check both "name" (bare) and "name[0]" (indexed) forms since the
1653                // optional_fields registry may use either convention.
1654                let path_with_idx = format!("{path_so_far}[0]");
1655                let is_opt = optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1656                if is_opt {
1657                    out.push_str(&format!(".as_ref().unwrap()[{index}]"));
1658                } else {
1659                    out.push_str(&format!("[{index}]"));
1660                }
1661                // Record the normalised "[0]" suffix in path_so_far so that deeper
1662                // optional-field keys which include explicit indices (e.g.
1663                // "choices[0].message.tool_calls") continue to match when we check
1664                // subsequent segments.
1665                path_so_far.push_str("[0]");
1666            }
1667            PathSegment::MapAccess { field, key } => {
1668                if !path_so_far.is_empty() {
1669                    path_so_far.push('.');
1670                }
1671                path_so_far.push_str(field);
1672                out.push('.');
1673                out.push_str(&field.to_snake_case());
1674                if key.chars().all(|c| c.is_ascii_digit()) {
1675                    // Check optional both with and without the numeric index suffix.
1676                    let path_with_idx = format!("{path_so_far}[0]");
1677                    let is_opt =
1678                        optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1679                    if is_opt {
1680                        out.push_str(&format!(".as_ref().unwrap()[{key}]"));
1681                    } else {
1682                        out.push_str(&format!("[{key}]"));
1683                    }
1684                    path_so_far.push_str("[0]");
1685                } else {
1686                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
1687                }
1688            }
1689            PathSegment::Length => {
1690                out.push_str(".len()");
1691            }
1692        }
1693    }
1694    out
1695}
1696
1697/// Zig accessor that unwraps optional fields with `.?`.
1698///
1699/// Zig does not allow field access, indexing, or comparisons through `?T`;
1700/// the value must be unwrapped first. Each segment whose path appears in the
1701/// optional-field set is followed by `.?` so the resulting expression is a
1702/// concrete value usable in assertions.
1703///
1704/// Paths in `method_calls` represent tagged-union variant accessors (Rust
1705/// variant getters such as `FormatMetadata::excel()`). In Zig, tagged-union
1706/// variants are accessed via the same dot syntax as struct fields, so the
1707/// segment is emitted as `.{name}` *without* `.?` even if the path also
1708/// appears in `optional_fields`.
1709fn render_zig_with_optionals(
1710    segments: &[PathSegment],
1711    result_var: &str,
1712    optional_fields: &HashSet<String>,
1713    method_calls: &HashSet<String>,
1714) -> String {
1715    let mut out = result_var.to_string();
1716    let mut path_so_far = String::new();
1717    for seg in segments {
1718        match seg {
1719            PathSegment::Field(f) => {
1720                if !path_so_far.is_empty() {
1721                    path_so_far.push('.');
1722                }
1723                path_so_far.push_str(f);
1724                out.push('.');
1725                out.push_str(f);
1726                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1727                    out.push_str(".?");
1728                }
1729            }
1730            PathSegment::ArrayField { name, index } => {
1731                if !path_so_far.is_empty() {
1732                    path_so_far.push('.');
1733                }
1734                path_so_far.push_str(name);
1735                out.push('.');
1736                out.push_str(name);
1737                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1738                    out.push_str(".?");
1739                }
1740                out.push_str(&format!("[{index}]"));
1741            }
1742            PathSegment::MapAccess { field, key } => {
1743                if !path_so_far.is_empty() {
1744                    path_so_far.push('.');
1745                }
1746                path_so_far.push_str(field);
1747                out.push('.');
1748                out.push_str(field);
1749                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1750                    out.push_str(".?");
1751                }
1752                if key.chars().all(|c| c.is_ascii_digit()) {
1753                    out.push_str(&format!("[{key}]"));
1754                } else {
1755                    out.push_str(&format!(".get(\"{key}\")"));
1756                }
1757            }
1758            PathSegment::Length => {
1759                out.push_str(".len");
1760            }
1761        }
1762    }
1763    out
1764}
1765
1766fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
1767    let mut out = result_var.to_string();
1768    for seg in segments {
1769        match seg {
1770            PathSegment::Field(f) => {
1771                out.push('.');
1772                out.push_str(&f.to_pascal_case());
1773            }
1774            PathSegment::ArrayField { name, index } => {
1775                out.push('.');
1776                out.push_str(&name.to_pascal_case());
1777                out.push_str(&format!("[{index}]"));
1778            }
1779            PathSegment::MapAccess { field, key } => {
1780                out.push('.');
1781                out.push_str(&field.to_pascal_case());
1782                if key.chars().all(|c| c.is_ascii_digit()) {
1783                    out.push_str(&format!("[{key}]"));
1784                } else {
1785                    out.push_str(&format!("[\"{key}\"]"));
1786                }
1787            }
1788            PathSegment::Length => {
1789                out.push_str(".Count");
1790            }
1791        }
1792    }
1793    out
1794}
1795
1796fn render_csharp_with_optionals(
1797    segments: &[PathSegment],
1798    result_var: &str,
1799    optional_fields: &HashSet<String>,
1800) -> String {
1801    let mut out = result_var.to_string();
1802    let mut path_so_far = String::new();
1803    for (i, seg) in segments.iter().enumerate() {
1804        let is_leaf = i == segments.len() - 1;
1805        match seg {
1806            PathSegment::Field(f) => {
1807                if !path_so_far.is_empty() {
1808                    path_so_far.push('.');
1809                }
1810                path_so_far.push_str(f);
1811                out.push('.');
1812                out.push_str(&f.to_pascal_case());
1813                if !is_leaf && optional_fields.contains(&path_so_far) {
1814                    out.push('!');
1815                }
1816            }
1817            PathSegment::ArrayField { name, index } => {
1818                if !path_so_far.is_empty() {
1819                    path_so_far.push('.');
1820                }
1821                path_so_far.push_str(name);
1822                out.push('.');
1823                out.push_str(&name.to_pascal_case());
1824                out.push_str(&format!("[{index}]"));
1825            }
1826            PathSegment::MapAccess { field, key } => {
1827                if !path_so_far.is_empty() {
1828                    path_so_far.push('.');
1829                }
1830                path_so_far.push_str(field);
1831                out.push('.');
1832                out.push_str(&field.to_pascal_case());
1833                if key.chars().all(|c| c.is_ascii_digit()) {
1834                    out.push_str(&format!("[{key}]"));
1835                } else {
1836                    out.push_str(&format!("[\"{key}\"]"));
1837                }
1838            }
1839            PathSegment::Length => {
1840                out.push_str(".Count");
1841            }
1842        }
1843    }
1844    out
1845}
1846
1847fn render_php(segments: &[PathSegment], result_var: &str) -> String {
1848    let mut out = result_var.to_string();
1849    for seg in segments {
1850        match seg {
1851            PathSegment::Field(f) => {
1852                out.push_str("->");
1853                // PHP properties are camelCase (per #[php(prop, name = "...")]),
1854                // so convert snake_case field names to camelCase.
1855                out.push_str(&f.to_lower_camel_case());
1856            }
1857            PathSegment::ArrayField { name, index } => {
1858                out.push_str("->");
1859                out.push_str(&name.to_lower_camel_case());
1860                out.push_str(&format!("[{index}]"));
1861            }
1862            PathSegment::MapAccess { field, key } => {
1863                out.push_str("->");
1864                out.push_str(&field.to_lower_camel_case());
1865                out.push_str(&format!("[\"{key}\"]"));
1866            }
1867            PathSegment::Length => {
1868                let current = std::mem::take(&mut out);
1869                out = format!("count({current})");
1870            }
1871        }
1872    }
1873    out
1874}
1875
1876/// PHP accessor that distinguishes between scalar fields (property access: `->camelCase`)
1877/// and non-scalar fields (getter-method access: `->getCamelCase()`).
1878///
1879/// ext-php-rs 0.15.x exposes scalar fields via `#[php(prop)]` as PHP properties, but
1880/// non-scalar fields (Named structs, `Vec<Named>`, `Map`, etc.) require a `#[php(getter)]`
1881/// method because `get_method_props` is `todo!()` in ext-php-rs-derive 0.11.7.
1882/// The generated getter method name is `get{CamelCase}` (stripping the `get_` prefix and
1883/// converting the camelCase remainder to a PHP property name), so e2e assertions must call
1884/// `->getCamelCase()` for those fields.
1885///
1886/// `getter_map` carries the per-`(owner_type, field_name)` classification along with the
1887/// chain-resolution metadata required to walk multi-segment paths through the IR's nested
1888/// type graph. Each path segment is classified using the *current* owner type, then the
1889/// owner cursor advances to the field's referenced `Named` type (if any) for the next
1890/// segment. When `root_type` is unset the renderer falls back to the legacy bare-name
1891/// union, which is unsafe but preserves backwards compatibility for callers that have
1892/// not wired type resolution.
1893fn render_php_with_getters(segments: &[PathSegment], result_var: &str, getter_map: &PhpGetterMap) -> String {
1894    let mut out = result_var.to_string();
1895    let mut current_type: Option<String> = getter_map.root_type.clone();
1896    for seg in segments {
1897        match seg {
1898            PathSegment::Field(f) => {
1899                let camel = f.to_lower_camel_case();
1900                if getter_map.needs_getter(current_type.as_deref(), f.as_str()) {
1901                    // Non-scalar field: ext-php-rs emits a `get{CamelCase}()` method.
1902                    // The `get_` prefix is stripped by ext-php-rs when it derives the
1903                    // PHP property name, but the Rust method ident is `get_{camelCase}`,
1904                    // so the PHP call is `->get{CamelCase}()`.
1905                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1906                    out.push_str("->");
1907                    out.push_str(&getter);
1908                    out.push_str("()");
1909                } else {
1910                    out.push_str("->");
1911                    out.push_str(&camel);
1912                }
1913                current_type = getter_map.advance(current_type.as_deref(), f.as_str());
1914            }
1915            PathSegment::ArrayField { name, index } => {
1916                let camel = name.to_lower_camel_case();
1917                if getter_map.needs_getter(current_type.as_deref(), name.as_str()) {
1918                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1919                    out.push_str("->");
1920                    out.push_str(&getter);
1921                    out.push_str("()");
1922                } else {
1923                    out.push_str("->");
1924                    out.push_str(&camel);
1925                }
1926                out.push_str(&format!("[{index}]"));
1927                current_type = getter_map.advance(current_type.as_deref(), name.as_str());
1928            }
1929            PathSegment::MapAccess { field, key } => {
1930                let camel = field.to_lower_camel_case();
1931                if getter_map.needs_getter(current_type.as_deref(), field.as_str()) {
1932                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1933                    out.push_str("->");
1934                    out.push_str(&getter);
1935                    out.push_str("()");
1936                } else {
1937                    out.push_str("->");
1938                    out.push_str(&camel);
1939                }
1940                out.push_str(&format!("[\"{key}\"]"));
1941                current_type = getter_map.advance(current_type.as_deref(), field.as_str());
1942            }
1943            PathSegment::Length => {
1944                let current = std::mem::take(&mut out);
1945                out = format!("count({current})");
1946            }
1947        }
1948    }
1949    out
1950}
1951
1952fn render_r(segments: &[PathSegment], result_var: &str) -> String {
1953    let mut out = result_var.to_string();
1954    for seg in segments {
1955        match seg {
1956            PathSegment::Field(f) => {
1957                out.push('$');
1958                out.push_str(f);
1959            }
1960            PathSegment::ArrayField { name, index } => {
1961                out.push('$');
1962                out.push_str(name);
1963                // R uses 1-based indexing.
1964                out.push_str(&format!("[[{}]]", index + 1));
1965            }
1966            PathSegment::MapAccess { field, key } => {
1967                out.push('$');
1968                out.push_str(field);
1969                out.push_str(&format!("[[\"{key}\"]]"));
1970            }
1971            PathSegment::Length => {
1972                let current = std::mem::take(&mut out);
1973                out = format!("length({current})");
1974            }
1975        }
1976    }
1977    out
1978}
1979
1980fn render_c(segments: &[PathSegment], result_var: &str) -> String {
1981    let mut parts = Vec::new();
1982    let mut trailing_length = false;
1983    for seg in segments {
1984        match seg {
1985            PathSegment::Field(f) => parts.push(f.to_snake_case()),
1986            PathSegment::ArrayField { name, .. } => parts.push(name.to_snake_case()),
1987            PathSegment::MapAccess { field, key } => {
1988                parts.push(field.to_snake_case());
1989                parts.push(key.clone());
1990            }
1991            PathSegment::Length => {
1992                trailing_length = true;
1993            }
1994        }
1995    }
1996    let suffix = parts.join("_");
1997    if trailing_length {
1998        format!("result_{suffix}_count({result_var})")
1999    } else {
2000        format!("result_{suffix}({result_var})")
2001    }
2002}
2003
2004/// Dart accessor using camelCase field names (FRB v2 convention).
2005///
2006/// FRB v2 generates Dart property getters with camelCase names for every
2007/// snake_case Rust field, so `snake_case_field` becomes `snakeCaseField`.
2008/// Array fields index with `[N]`; map fields use `["key"]` or `[N]` notation.
2009/// Length/count segments use `.length` (Dart `List.length`).
2010fn render_dart(segments: &[PathSegment], result_var: &str) -> String {
2011    let mut out = result_var.to_string();
2012    for seg in segments {
2013        match seg {
2014            PathSegment::Field(f) => {
2015                out.push('.');
2016                out.push_str(&f.to_lower_camel_case());
2017            }
2018            PathSegment::ArrayField { name, index } => {
2019                out.push('.');
2020                out.push_str(&name.to_lower_camel_case());
2021                out.push_str(&format!("[{index}]"));
2022            }
2023            PathSegment::MapAccess { field, key } => {
2024                out.push('.');
2025                out.push_str(&field.to_lower_camel_case());
2026                if key.chars().all(|c| c.is_ascii_digit()) {
2027                    out.push_str(&format!("[{key}]"));
2028                } else {
2029                    out.push_str(&format!("[\"{key}\"]"));
2030                }
2031            }
2032            PathSegment::Length => {
2033                out.push_str(".length");
2034            }
2035        }
2036    }
2037    out
2038}
2039
2040/// Dart accessor with optional-safe navigation using `?.` (FRB v2 convention).
2041///
2042/// When an intermediate field is in `optional_fields`, the next segment uses
2043/// `?.` safe-call navigation instead of `.` to avoid a null-dereference on
2044/// a nullable Dart type.  Field names are camelCase (FRB v2 generation rule).
2045fn render_dart_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
2046    let mut out = result_var.to_string();
2047    let mut path_so_far = String::new();
2048    let mut prev_was_nullable = false;
2049    for seg in segments {
2050        let nav = if prev_was_nullable { "?." } else { "." };
2051        match seg {
2052            PathSegment::Field(f) => {
2053                if !path_so_far.is_empty() {
2054                    path_so_far.push('.');
2055                }
2056                path_so_far.push_str(f);
2057                let is_optional = optional_fields.contains(&path_so_far);
2058                out.push_str(nav);
2059                out.push_str(&f.to_lower_camel_case());
2060                prev_was_nullable = is_optional;
2061            }
2062            PathSegment::ArrayField { name, index } => {
2063                if !path_so_far.is_empty() {
2064                    path_so_far.push('.');
2065                }
2066                path_so_far.push_str(name);
2067                let is_optional = optional_fields.contains(&path_so_far);
2068                out.push_str(nav);
2069                out.push_str(&name.to_lower_camel_case());
2070                // FRB models `Option<Vec<T>>` as `List<T>?` — only force-unwrap when the field
2071                // is registered as optional. Adding `!` to a non-nullable receiver is a Dart
2072                // compile-time error ("unnecessary non-null assertion").
2073                if is_optional {
2074                    out.push('!');
2075                }
2076                out.push_str(&format!("[{index}]"));
2077                prev_was_nullable = false;
2078            }
2079            PathSegment::MapAccess { field, key } => {
2080                if !path_so_far.is_empty() {
2081                    path_so_far.push('.');
2082                }
2083                path_so_far.push_str(field);
2084                let is_optional = optional_fields.contains(&path_so_far);
2085                out.push_str(nav);
2086                out.push_str(&field.to_lower_camel_case());
2087                if key.chars().all(|c| c.is_ascii_digit()) {
2088                    out.push_str(&format!("[{key}]"));
2089                } else {
2090                    out.push_str(&format!("[\"{key}\"]"));
2091                }
2092                prev_was_nullable = is_optional;
2093            }
2094            PathSegment::Length => {
2095                // Use `?.length` when the receiver is optional — emitting `.length` against
2096                // a `List<T>?` is a Dart sound-null-safety error.
2097                out.push_str(nav);
2098                out.push_str("length");
2099                prev_was_nullable = false;
2100            }
2101        }
2102    }
2103    out
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108    use super::*;
2109
2110    fn make_resolver() -> FieldResolver {
2111        let mut fields = HashMap::new();
2112        fields.insert("title".to_string(), "metadata.document.title".to_string());
2113        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
2114        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
2115        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
2116        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
2117        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
2118        let mut optional = HashSet::new();
2119        optional.insert("metadata.document.title".to_string());
2120        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
2121    }
2122
2123    fn make_resolver_with_doc_optional() -> FieldResolver {
2124        let mut fields = HashMap::new();
2125        fields.insert("title".to_string(), "metadata.document.title".to_string());
2126        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
2127        let mut optional = HashSet::new();
2128        optional.insert("document".to_string());
2129        optional.insert("metadata.document.title".to_string());
2130        optional.insert("metadata.document".to_string());
2131        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
2132    }
2133
2134    #[test]
2135    fn test_resolve_alias() {
2136        let r = make_resolver();
2137        assert_eq!(r.resolve("title"), "metadata.document.title");
2138    }
2139
2140    #[test]
2141    fn test_resolve_passthrough() {
2142        let r = make_resolver();
2143        assert_eq!(r.resolve("content"), "content");
2144    }
2145
2146    #[test]
2147    fn test_is_optional() {
2148        let r = make_resolver();
2149        assert!(r.is_optional("metadata.document.title"));
2150        assert!(!r.is_optional("content"));
2151    }
2152
2153    #[test]
2154    fn is_optional_strips_namespace_prefix() {
2155        let fields = HashMap::new();
2156        let mut optional = HashSet::new();
2157        optional.insert("action_results.data".to_string());
2158        let result_fields: HashSet<String> = ["action_results".to_string()].into_iter().collect();
2159        let r = FieldResolver::new(&fields, &optional, &result_fields, &HashSet::new(), &HashSet::new());
2160        // `interaction.` is a virtual namespace prefix — strip and re-check.
2161        assert!(r.is_optional("interaction.action_results[0].data"));
2162        // Still finds it without the prefix.
2163        assert!(r.is_optional("action_results[0].data"));
2164    }
2165
2166    #[test]
2167    fn test_accessor_rust_struct() {
2168        let r = make_resolver();
2169        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
2170    }
2171
2172    #[test]
2173    fn test_accessor_rust_map() {
2174        let r = make_resolver();
2175        assert_eq!(
2176            r.accessor("tags", "rust", "result"),
2177            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
2178        );
2179    }
2180
2181    #[test]
2182    fn test_accessor_python() {
2183        let r = make_resolver();
2184        assert_eq!(
2185            r.accessor("title", "python", "result"),
2186            "result.metadata.document.title"
2187        );
2188    }
2189
2190    #[test]
2191    fn test_accessor_go() {
2192        let r = make_resolver();
2193        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
2194    }
2195
2196    #[test]
2197    fn test_accessor_go_initialism_fields() {
2198        let mut fields = std::collections::HashMap::new();
2199        fields.insert("content".to_string(), "html".to_string());
2200        fields.insert("link_url".to_string(), "links.url".to_string());
2201        let r = FieldResolver::new(
2202            &fields,
2203            &HashSet::new(),
2204            &HashSet::new(),
2205            &HashSet::new(),
2206            &HashSet::new(),
2207        );
2208        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
2209        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
2210        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
2211        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
2212        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
2213        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
2214        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
2215        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
2216    }
2217
2218    #[test]
2219    fn test_accessor_typescript() {
2220        let r = make_resolver();
2221        assert_eq!(
2222            r.accessor("title", "typescript", "result"),
2223            "result.metadata.document.title"
2224        );
2225    }
2226
2227    #[test]
2228    fn test_accessor_typescript_snake_to_camel() {
2229        let r = make_resolver();
2230        assert_eq!(
2231            r.accessor("og", "typescript", "result"),
2232            "result.metadata.document.openGraph"
2233        );
2234        assert_eq!(
2235            r.accessor("twitter", "typescript", "result"),
2236            "result.metadata.document.twitterCard"
2237        );
2238        assert_eq!(
2239            r.accessor("canonical", "typescript", "result"),
2240            "result.metadata.document.canonicalUrl"
2241        );
2242    }
2243
2244    #[test]
2245    fn test_accessor_typescript_map_snake_to_camel() {
2246        let r = make_resolver();
2247        assert_eq!(
2248            r.accessor("og_tag", "typescript", "result"),
2249            "result.metadata.openGraphTags[\"og_title\"]"
2250        );
2251    }
2252
2253    #[test]
2254    fn test_accessor_typescript_numeric_index_is_unquoted() {
2255        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
2256        // must emit numeric bracket access (`[0]`) not string-keyed access
2257        // (`["0"]`), which would return undefined on arrays.
2258        let mut fields = HashMap::new();
2259        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
2260        let r = FieldResolver::new(
2261            &fields,
2262            &HashSet::new(),
2263            &HashSet::new(),
2264            &HashSet::new(),
2265            &HashSet::new(),
2266        );
2267        assert_eq!(
2268            r.accessor("first_score", "typescript", "result"),
2269            "result.results[0].relevanceScore"
2270        );
2271    }
2272
2273    #[test]
2274    fn test_accessor_node_alias() {
2275        let r = make_resolver();
2276        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
2277    }
2278
2279    #[test]
2280    fn test_accessor_wasm_camel_case() {
2281        let r = make_resolver();
2282        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
2283        assert_eq!(
2284            r.accessor("twitter", "wasm", "result"),
2285            "result.metadata.document.twitterCard"
2286        );
2287        assert_eq!(
2288            r.accessor("canonical", "wasm", "result"),
2289            "result.metadata.document.canonicalUrl"
2290        );
2291    }
2292
2293    #[test]
2294    fn test_accessor_wasm_map_access() {
2295        let r = make_resolver();
2296        assert_eq!(
2297            r.accessor("og_tag", "wasm", "result"),
2298            "result.metadata.openGraphTags.get(\"og_title\")"
2299        );
2300    }
2301
2302    #[test]
2303    fn test_accessor_java() {
2304        let r = make_resolver();
2305        assert_eq!(
2306            r.accessor("title", "java", "result"),
2307            "result.metadata().document().title()"
2308        );
2309    }
2310
2311    #[test]
2312    fn test_accessor_kotlin_uses_kotlin_collection_idioms() {
2313        let mut fields = HashMap::new();
2314        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2315        fields.insert("node_count".to_string(), "nodes.length".to_string());
2316        let mut arrays = HashSet::new();
2317        arrays.insert("nodes".to_string());
2318        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &arrays, &HashSet::new());
2319        assert_eq!(
2320            r.accessor("first_node_name", "kotlin", "result"),
2321            "result.nodes().first().name()"
2322        );
2323        assert_eq!(r.accessor("node_count", "kotlin", "result"), "result.nodes().size");
2324    }
2325
2326    #[test]
2327    fn test_accessor_kotlin_uses_safe_calls_for_optional_prefixes() {
2328        let r = make_resolver_with_doc_optional();
2329        assert_eq!(
2330            r.accessor("title", "kotlin", "result"),
2331            "result.metadata().document()?.title()"
2332        );
2333    }
2334
2335    #[test]
2336    fn test_accessor_kotlin_uses_safe_calls_for_optional_arrays_and_maps() {
2337        let mut fields = HashMap::new();
2338        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
2339        fields.insert("tag".to_string(), "tags[name]".to_string());
2340        let mut optional = HashSet::new();
2341        optional.insert("nodes".to_string());
2342        optional.insert("tags".to_string());
2343        let mut arrays = HashSet::new();
2344        arrays.insert("nodes".to_string());
2345        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2346        assert_eq!(
2347            r.accessor("first_node_name", "kotlin", "result"),
2348            "result.nodes()?.first()?.name()"
2349        );
2350        assert_eq!(r.accessor("tag", "kotlin", "result"), "result.tags()?.get(\"name\")");
2351    }
2352
2353    /// Regression: optional-field keys with explicit `[0]` indices (e.g.
2354    /// `"choices[0].message.tool_calls"`) were not matched by
2355    /// `render_kotlin_with_optionals` because `path_so_far` omitted the `[0]`
2356    /// suffix after traversing an ArrayField segment. Fix: append `"[0]"` to
2357    /// `path_so_far` after each ArrayField, mirroring the Rust renderer.
2358    #[test]
2359    fn test_accessor_kotlin_optional_field_after_indexed_array() {
2360        // "choices[0].message.tool_calls" is optional; the path is accessed as
2361        // choices[0].message.tool_calls[0].function.name.
2362        let mut fields = HashMap::new();
2363        fields.insert(
2364            "tool_call_name".to_string(),
2365            "choices[0].message.tool_calls[0].function.name".to_string(),
2366        );
2367        let mut optional = HashSet::new();
2368        optional.insert("choices[0].message.tool_calls".to_string());
2369        let mut arrays = HashSet::new();
2370        arrays.insert("choices".to_string());
2371        arrays.insert("choices[0].message.tool_calls".to_string());
2372        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2373        let expr = r.accessor("tool_call_name", "kotlin", "result");
2374        // toolCalls() is optional so it must use `?.` before `.first()`.
2375        assert!(
2376            expr.contains("toolCalls()?.first()"),
2377            "expected toolCalls()?.first() for optional list, got: {expr}"
2378        );
2379    }
2380
2381    #[test]
2382    fn test_accessor_csharp() {
2383        let r = make_resolver();
2384        assert_eq!(
2385            r.accessor("title", "csharp", "result"),
2386            "result.Metadata.Document.Title"
2387        );
2388    }
2389
2390    #[test]
2391    fn test_accessor_php() {
2392        let r = make_resolver();
2393        assert_eq!(
2394            r.accessor("title", "php", "$result"),
2395            "$result->metadata->document->title"
2396        );
2397    }
2398
2399    #[test]
2400    fn test_accessor_r() {
2401        let r = make_resolver();
2402        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
2403    }
2404
2405    #[test]
2406    fn test_accessor_c() {
2407        let r = make_resolver();
2408        assert_eq!(
2409            r.accessor("title", "c", "result"),
2410            "result_metadata_document_title(result)"
2411        );
2412    }
2413
2414    #[test]
2415    fn test_rust_unwrap_binding() {
2416        let r = make_resolver();
2417        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
2418        assert_eq!(var, "metadata_document_title");
2419        // Optional scalar fields are unwrapped via `Debug` format so types that don't
2420        // implement `Display` (e.g. enum metadata) still convert to strings for comparison.
2421        assert!(binding.contains("as_ref().map(|v| format!(\"{:?}\", v)).unwrap_or_default()"));
2422    }
2423
2424    #[test]
2425    fn test_rust_unwrap_binding_non_optional() {
2426        let r = make_resolver();
2427        assert!(r.rust_unwrap_binding("content", "result").is_none());
2428    }
2429
2430    #[test]
2431    fn test_rust_unwrap_binding_collapses_double_underscore() {
2432        // When an alias resolves to a path with `[]` (e.g. `json_ld.name` →
2433        // `json_ld[].name`), the naive replace previously yielded `json_ld__name`,
2434        // which trips Rust's non_snake_case lint under -D warnings. The local
2435        // binding name must collapse consecutive underscores into one.
2436        let mut aliases = HashMap::new();
2437        aliases.insert("json_ld.name".to_string(), "json_ld[].name".to_string());
2438        let mut optional = HashSet::new();
2439        optional.insert("json_ld[].name".to_string());
2440        let mut array = HashSet::new();
2441        array.insert("json_ld".to_string());
2442        let result_fields = HashSet::new();
2443        let method_calls = HashSet::new();
2444        let r = FieldResolver::new(&aliases, &optional, &result_fields, &array, &method_calls);
2445        let (_binding, var) = r.rust_unwrap_binding("json_ld.name", "result").unwrap();
2446        assert_eq!(var, "json_ld_name");
2447    }
2448
2449    #[test]
2450    fn test_direct_field_no_alias() {
2451        let r = make_resolver();
2452        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2453        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
2454    }
2455
2456    #[test]
2457    fn test_accessor_rust_with_optionals() {
2458        let r = make_resolver_with_doc_optional();
2459        assert_eq!(
2460            r.accessor("title", "rust", "result"),
2461            "result.metadata.document.as_ref().unwrap().title"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_accessor_csharp_with_optionals() {
2467        let r = make_resolver_with_doc_optional();
2468        assert_eq!(
2469            r.accessor("title", "csharp", "result"),
2470            "result.Metadata.Document!.Title"
2471        );
2472    }
2473
2474    #[test]
2475    fn test_accessor_rust_non_optional_field() {
2476        let r = make_resolver();
2477        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2478    }
2479
2480    #[test]
2481    fn test_accessor_csharp_non_optional_field() {
2482        let r = make_resolver();
2483        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
2484    }
2485
2486    #[test]
2487    fn test_accessor_rust_method_call() {
2488        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
2489        let mut fields = HashMap::new();
2490        fields.insert(
2491            "excel_sheet_count".to_string(),
2492            "metadata.format.excel.sheet_count".to_string(),
2493        );
2494        let mut optional = HashSet::new();
2495        optional.insert("metadata.format".to_string());
2496        optional.insert("metadata.format.excel".to_string());
2497        let mut method_calls = HashSet::new();
2498        method_calls.insert("metadata.format.excel".to_string());
2499        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
2500        assert_eq!(
2501            r.accessor("excel_sheet_count", "rust", "result"),
2502            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
2503        );
2504    }
2505
2506    // ---------------------------------------------------------------------------
2507    // PHP getter-method tests (ext-php-rs 0.15.x `#[php(getter)]` vs `#[php(prop)]`)
2508    // ---------------------------------------------------------------------------
2509
2510    fn make_php_getter_resolver() -> FieldResolver {
2511        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2512        getters.insert(
2513            "Root".to_string(),
2514            ["metadata".to_string(), "links".to_string()].into_iter().collect(),
2515        );
2516        let map = PhpGetterMap {
2517            getters,
2518            field_types: HashMap::new(),
2519            root_type: Some("Root".to_string()),
2520            all_fields: HashMap::new(),
2521        };
2522        FieldResolver::new_with_php_getters(
2523            &HashMap::new(),
2524            &HashSet::new(),
2525            &HashSet::new(),
2526            &HashSet::new(),
2527            &HashSet::new(),
2528            &HashMap::new(),
2529            map,
2530        )
2531    }
2532
2533    #[test]
2534    fn render_php_uses_getter_method_for_non_scalar_field() {
2535        let r = make_php_getter_resolver();
2536        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->getMetadata()");
2537    }
2538
2539    #[test]
2540    fn render_php_uses_property_for_scalar_field() {
2541        let r = make_php_getter_resolver();
2542        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2543    }
2544
2545    #[test]
2546    fn render_php_nested_non_scalar_uses_getter_then_property() {
2547        let mut fields = HashMap::new();
2548        fields.insert("title".to_string(), "metadata.title".to_string());
2549        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2550        getters.insert("Root".to_string(), ["metadata".to_string()].into_iter().collect());
2551        // No entry for Metadata.title → scalar by default.
2552        getters.insert("Metadata".to_string(), HashSet::new());
2553        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2554        field_types.insert(
2555            "Root".to_string(),
2556            [("metadata".to_string(), "Metadata".to_string())].into_iter().collect(),
2557        );
2558        let map = PhpGetterMap {
2559            getters,
2560            field_types,
2561            root_type: Some("Root".to_string()),
2562            all_fields: HashMap::new(),
2563        };
2564        let r = FieldResolver::new_with_php_getters(
2565            &fields,
2566            &HashSet::new(),
2567            &HashSet::new(),
2568            &HashSet::new(),
2569            &HashSet::new(),
2570            &HashMap::new(),
2571            map,
2572        );
2573        // `metadata` → `->getMetadata()`, then `title` (scalar on returned object) → `->title`
2574        assert_eq!(r.accessor("title", "php", "$result"), "$result->getMetadata()->title");
2575    }
2576
2577    #[test]
2578    fn render_php_array_field_uses_getter_when_non_scalar() {
2579        let mut fields = HashMap::new();
2580        fields.insert("first_link".to_string(), "links[0]".to_string());
2581        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2582        getters.insert("Root".to_string(), ["links".to_string()].into_iter().collect());
2583        let map = PhpGetterMap {
2584            getters,
2585            field_types: HashMap::new(),
2586            root_type: Some("Root".to_string()),
2587            all_fields: HashMap::new(),
2588        };
2589        let r = FieldResolver::new_with_php_getters(
2590            &fields,
2591            &HashSet::new(),
2592            &HashSet::new(),
2593            &HashSet::new(),
2594            &HashSet::new(),
2595            &HashMap::new(),
2596            map,
2597        );
2598        assert_eq!(r.accessor("first_link", "php", "$result"), "$result->getLinks()[0]");
2599    }
2600
2601    #[test]
2602    fn render_php_falls_back_to_property_when_getter_fields_empty() {
2603        // With empty php_getter_map the resolver uses the plain `render_php` path,
2604        // which emits `->camelCase` for every field regardless of scalar-ness.
2605        let r = FieldResolver::new(
2606            &HashMap::new(),
2607            &HashSet::new(),
2608            &HashSet::new(),
2609            &HashSet::new(),
2610            &HashSet::new(),
2611        );
2612        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2613        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->metadata");
2614    }
2615
2616    // Regression: bare-name HashSet classification produced false getters when two
2617    // types shared a field name with different scalarness (kreuzcrawl `content`
2618    // collision between CrawlConfig.content: ContentConfig and MarkdownResult.content: String).
2619    #[test]
2620    fn render_php_with_getters_distinguishes_same_field_name_on_different_types() {
2621        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2622        // A.content is non-scalar.
2623        getters.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2624        // B.content is scalar — explicit empty set.
2625        getters.insert("B".to_string(), HashSet::new());
2626        // Both A and B declare a "content" field — needed so the per-type
2627        // classification is consulted (not fallback bare-name union).
2628        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2629        all_fields.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2630        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2631        let map_a = PhpGetterMap {
2632            getters: getters.clone(),
2633            field_types: HashMap::new(),
2634            root_type: Some("A".to_string()),
2635            all_fields: all_fields.clone(),
2636        };
2637        let map_b = PhpGetterMap {
2638            getters,
2639            field_types: HashMap::new(),
2640            root_type: Some("B".to_string()),
2641            all_fields,
2642        };
2643        let r_a = FieldResolver::new_with_php_getters(
2644            &HashMap::new(),
2645            &HashSet::new(),
2646            &HashSet::new(),
2647            &HashSet::new(),
2648            &HashSet::new(),
2649            &HashMap::new(),
2650            map_a,
2651        );
2652        let r_b = FieldResolver::new_with_php_getters(
2653            &HashMap::new(),
2654            &HashSet::new(),
2655            &HashSet::new(),
2656            &HashSet::new(),
2657            &HashSet::new(),
2658            &HashMap::new(),
2659            map_b,
2660        );
2661        assert_eq!(r_a.accessor("content", "php", "$a"), "$a->getContent()");
2662        assert_eq!(r_b.accessor("content", "php", "$b"), "$b->content");
2663    }
2664
2665    // Regression: the chain renderer must advance current_type through the IR's
2666    // nested-type graph so a scalar field on a nested type is not falsely
2667    // classified as needing a getter because some other type uses the same name.
2668    #[test]
2669    fn render_php_with_getters_chains_through_correct_type() {
2670        let mut fields = HashMap::new();
2671        fields.insert("nested_content".to_string(), "inner.content".to_string());
2672        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2673        // Outer.inner is non-scalar (struct B).
2674        getters.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2675        // B.content is scalar.
2676        getters.insert("B".to_string(), HashSet::new());
2677        // Decoy: another type with non-scalar `content` field — used to verify
2678        // the legacy bare-name union would have produced the wrong answer.
2679        getters.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2680        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2681        field_types.insert(
2682            "Outer".to_string(),
2683            [("inner".to_string(), "B".to_string())].into_iter().collect(),
2684        );
2685        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2686        all_fields.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2687        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2688        all_fields.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2689        let map = PhpGetterMap {
2690            getters,
2691            field_types,
2692            root_type: Some("Outer".to_string()),
2693            all_fields,
2694        };
2695        let r = FieldResolver::new_with_php_getters(
2696            &fields,
2697            &HashSet::new(),
2698            &HashSet::new(),
2699            &HashSet::new(),
2700            &HashSet::new(),
2701            &HashMap::new(),
2702            map,
2703        );
2704        assert_eq!(
2705            r.accessor("nested_content", "php", "$result"),
2706            "$result->getInner()->content"
2707        );
2708    }
2709
2710    // ---------------------------------------------------------------------------
2711    // Namespace-prefix stripping tests
2712    // ---------------------------------------------------------------------------
2713
2714    fn make_resolver_with_result_fields(result_fields: &[&str]) -> FieldResolver {
2715        let rf: HashSet<String> = result_fields.iter().map(|s| s.to_string()).collect();
2716        FieldResolver::new(&HashMap::new(), &HashSet::new(), &rf, &HashSet::new(), &HashSet::new())
2717    }
2718
2719    /// `browser.browser_used` — `browser` is a virtual namespace prefix, actual
2720    /// field is `browser_used` which IS in result_fields.
2721    #[test]
2722    fn is_valid_for_result_accepts_virtual_namespace_prefix() {
2723        let r = make_resolver_with_result_fields(&["browser_used", "js_render_hint", "status_code"]);
2724        assert!(
2725            r.is_valid_for_result("browser.browser_used"),
2726            "browser.browser_used should be valid via namespace-prefix stripping"
2727        );
2728        assert!(
2729            r.is_valid_for_result("browser.js_render_hint"),
2730            "browser.js_render_hint should be valid via namespace-prefix stripping"
2731        );
2732    }
2733
2734    /// `interaction.action_results[0].action_type` — `interaction` is a virtual
2735    /// namespace prefix, `action_results` IS in result_fields.
2736    #[test]
2737    fn is_valid_for_result_accepts_namespace_prefix_before_array_field() {
2738        let r = make_resolver_with_result_fields(&["action_results", "final_html", "final_url"]);
2739        assert!(
2740            r.is_valid_for_result("interaction.action_results[0].action_type"),
2741            "interaction. prefix should be stripped so action_results is recognised"
2742        );
2743    }
2744
2745    /// Fields that genuinely don't exist should still be rejected.
2746    #[test]
2747    fn is_valid_for_result_rejects_unknown_field_even_after_namespace_strip() {
2748        let r = make_resolver_with_result_fields(&["pages", "final_url"]);
2749        assert!(
2750            !r.is_valid_for_result("browser.browser_used"),
2751            "browser_used is not in result_fields so should be rejected"
2752        );
2753        assert!(
2754            !r.is_valid_for_result("ns.unknown_field"),
2755            "unknown_field is not in result_fields so should be rejected"
2756        );
2757    }
2758
2759    /// Accessor for `browser.browser_used` should produce the stripped path
2760    /// (i.e. `result.browser_used` for Python, not `result.browser.browser_used`).
2761    #[test]
2762    fn accessor_strips_namespace_prefix_for_python() {
2763        let r = make_resolver_with_result_fields(&["browser_used", "js_render_hint"]);
2764        assert_eq!(
2765            r.accessor("browser.browser_used", "python", "result"),
2766            "result.browser_used"
2767        );
2768        assert_eq!(
2769            r.accessor("browser.js_render_hint", "python", "result"),
2770            "result.js_render_hint"
2771        );
2772    }
2773
2774    /// Accessor for `browser.browser_used` should produce PascalCase path for C#.
2775    #[test]
2776    fn accessor_strips_namespace_prefix_for_csharp() {
2777        let r = make_resolver_with_result_fields(&["browser_used"]);
2778        assert_eq!(
2779            r.accessor("browser.browser_used", "csharp", "result"),
2780            "result.BrowserUsed"
2781        );
2782    }
2783
2784    /// Accessor for `interaction.action_results[0].action_type` — strips `interaction.`
2785    /// prefix and resolves the remaining path.
2786    #[test]
2787    fn accessor_strips_namespace_prefix_for_indexed_array_field() {
2788        let r = make_resolver_with_result_fields(&["action_results", "final_html", "final_url"]);
2789        // Python: result.action_results[0].action_type
2790        assert_eq!(
2791            r.accessor("interaction.action_results[0].action_type", "python", "result"),
2792            "result.action_results[0].action_type"
2793        );
2794        // TypeScript: result.actionResults[0].actionType
2795        assert_eq!(
2796            r.accessor("interaction.action_results[0].action_type", "typescript", "result"),
2797            "result.actionResults[0].actionType"
2798        );
2799    }
2800
2801    /// When `result_fields` is empty, namespace stripping is disabled and every
2802    /// path is accepted (the permissive default).
2803    #[test]
2804    fn is_valid_for_result_is_permissive_when_result_fields_empty() {
2805        let r = make_resolver_with_result_fields(&[]);
2806        assert!(r.is_valid_for_result("browser.browser_used"));
2807        assert!(r.is_valid_for_result("anything.at.all"));
2808    }
2809
2810    /// A real two-segment path like `metadata.title` where `metadata` IS a
2811    /// known result field must NOT be stripped — the full path resolves correctly.
2812    #[test]
2813    fn accessor_does_not_strip_real_first_segment() {
2814        let r = make_resolver_with_result_fields(&["metadata", "status_code"]);
2815        // `metadata` is a real result field; should not be stripped.
2816        assert_eq!(
2817            r.accessor("metadata.title", "python", "result"),
2818            "result.metadata.title"
2819        );
2820    }
2821}