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