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/// Swift-bridge exposes all Rust struct fields as methods with `()`, so every
606/// field segment must be followed by `()`. Array fields (e.g. `nodes` inside
607/// an array parent) also need `()`.
608fn render_swift(segments: &[PathSegment], result_var: &str) -> String {
609    let mut out = result_var.to_string();
610    for seg in segments {
611        match seg {
612            PathSegment::Field(f) => {
613                out.push('.');
614                out.push_str(f);
615                out.push_str("()");
616            }
617            PathSegment::ArrayField { name, index } => {
618                out.push('.');
619                out.push_str(name);
620                out.push_str(&format!("()[{index}]"));
621            }
622            PathSegment::MapAccess { field, key } => {
623                out.push('.');
624                out.push_str(field);
625                if key.chars().all(|c| c.is_ascii_digit()) {
626                    out.push_str(&format!("()[{key}]"));
627                } else {
628                    out.push_str(&format!("()[\"{key}\"]"));
629                }
630            }
631            PathSegment::Length => {
632                out.push_str(".count");
633            }
634        }
635    }
636    out
637}
638
639/// Generate a Swift accessor expression with optional chaining.
640///
641/// When an intermediate field is in `optional_fields`, a `?` is inserted after the
642/// `()` call on that segment so the next access uses `?.`. This prevents compile
643/// errors when accessing members through an `Optional<T>` in Swift.
644///
645/// Example: for `metadata.format.excel.sheet_count` where `metadata.format` and
646/// `metadata.format.excel` are optional, the result is:
647/// `result.metadata().format()?.excel()?.sheet_count()`
648fn render_swift_with_optionals(
649    segments: &[PathSegment],
650    result_var: &str,
651    optional_fields: &HashSet<String>,
652) -> String {
653    let mut out = result_var.to_string();
654    let mut path_so_far = String::new();
655    let total = segments.len();
656    for (i, seg) in segments.iter().enumerate() {
657        let is_leaf = i == total - 1;
658        match seg {
659            PathSegment::Field(f) => {
660                if !path_so_far.is_empty() {
661                    path_so_far.push('.');
662                }
663                path_so_far.push_str(f);
664                out.push('.');
665                out.push_str(f);
666                out.push_str("()");
667                // Insert `?` after `()` for non-leaf optional fields so the next
668                // member access becomes `?.`.
669                if !is_leaf && optional_fields.contains(&path_so_far) {
670                    out.push('?');
671                }
672            }
673            PathSegment::ArrayField { name, index } => {
674                if !path_so_far.is_empty() {
675                    path_so_far.push('.');
676                }
677                path_so_far.push_str(name);
678                // Check optionality before appending `[0]` to path_so_far so the
679                // lookup key matches the entry in optional_fields (e.g.
680                // "choices[0].message.tool_calls" not "choices[0].message.tool_calls[0]").
681                let is_optional = optional_fields.contains(&path_so_far);
682                out.push('.');
683                out.push_str(name);
684                if is_optional {
685                    // The getter returns Optional<RustVec<T>>; use `?` before the
686                    // subscript so Swift unwraps the Optional before indexing.
687                    out.push_str(&format!("()?[{index}]"));
688                } else {
689                    out.push_str(&format!("()[{index}]"));
690                }
691                // Append the normalised `[0]` suffix to path_so_far so that
692                // subsequent Field segments build paths like "choices[0].message"
693                // which match optional_fields entries that use indexed keys.
694                path_so_far.push_str("[0]");
695                // Do NOT append `?` after `[index]` even when the vec is optional.
696                // In swift-bridge, `RustVec<T>`'s subscript operator returns `T`
697                // directly (non-optional). After `vec()?[N]` Swift has already
698                // consumed the optional via the `?` before the subscript, so the
699                // value at `[N]` is a plain `T` — appending `?` here would produce
700                // a compiler error: "cannot use optional chaining on non-optional".
701                let _ = is_leaf;
702            }
703            PathSegment::MapAccess { field, key } => {
704                if !path_so_far.is_empty() {
705                    path_so_far.push('.');
706                }
707                path_so_far.push_str(field);
708                out.push('.');
709                out.push_str(field);
710                if key.chars().all(|c| c.is_ascii_digit()) {
711                    out.push_str(&format!("()[{key}]"));
712                } else {
713                    out.push_str(&format!("()[\"{key}\"]"));
714                }
715            }
716            PathSegment::Length => {
717                out.push_str(".count");
718            }
719        }
720    }
721    out
722}
723
724fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
725    let mut out = result_var.to_string();
726    for seg in segments {
727        match seg {
728            PathSegment::Field(f) => {
729                out.push('.');
730                out.push_str(&f.to_snake_case());
731            }
732            PathSegment::ArrayField { name, index } => {
733                out.push('.');
734                out.push_str(&name.to_snake_case());
735                out.push_str(&format!("[{index}]"));
736            }
737            PathSegment::MapAccess { field, key } => {
738                out.push('.');
739                out.push_str(&field.to_snake_case());
740                if key.chars().all(|c| c.is_ascii_digit()) {
741                    out.push_str(&format!("[{key}]"));
742                } else {
743                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
744                }
745            }
746            PathSegment::Length => {
747                out.push_str(".len()");
748            }
749        }
750    }
751    out
752}
753
754fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
755    let mut out = result_var.to_string();
756    for seg in segments {
757        match seg {
758            PathSegment::Field(f) => {
759                out.push('.');
760                out.push_str(f);
761            }
762            PathSegment::ArrayField { name, index } => {
763                if language == "elixir" {
764                    let current = std::mem::take(&mut out);
765                    out = format!("Enum.at({current}.{name}, {index})");
766                } else {
767                    out.push('.');
768                    out.push_str(name);
769                    out.push_str(&format!("[{index}]"));
770                }
771            }
772            PathSegment::MapAccess { field, key } => {
773                let is_numeric = key.chars().all(|c| c.is_ascii_digit());
774                if is_numeric && language == "elixir" {
775                    let current = std::mem::take(&mut out);
776                    out = format!("Enum.at({current}.{field}, {key})");
777                } else {
778                    out.push('.');
779                    out.push_str(field);
780                    if is_numeric {
781                        let idx: usize = key.parse().unwrap_or(0);
782                        out.push_str(&format!("[{idx}]"));
783                    } else if language == "elixir" || language == "ruby" {
784                        // Ruby/Elixir hashes use `["key"]` bracket access (Ruby's Hash has
785                        // no `get` method; Elixir maps use bracket access too).
786                        out.push_str(&format!("[\"{key}\"]"));
787                    } else {
788                        out.push_str(&format!(".get(\"{key}\")"));
789                    }
790                }
791            }
792            PathSegment::Length => match language {
793                "ruby" => out.push_str(".length"),
794                "elixir" => {
795                    let current = std::mem::take(&mut out);
796                    out = format!("length({current})");
797                }
798                "gleam" => {
799                    let current = std::mem::take(&mut out);
800                    out = format!("list.length({current})");
801                }
802                _ => {
803                    let current = std::mem::take(&mut out);
804                    out = format!("len({current})");
805                }
806            },
807        }
808    }
809    out
810}
811
812fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
813    let mut out = result_var.to_string();
814    for seg in segments {
815        match seg {
816            PathSegment::Field(f) => {
817                out.push('.');
818                out.push_str(&f.to_lower_camel_case());
819            }
820            PathSegment::ArrayField { name, index } => {
821                out.push('.');
822                out.push_str(&name.to_lower_camel_case());
823                out.push_str(&format!("[{index}]"));
824            }
825            PathSegment::MapAccess { field, key } => {
826                out.push('.');
827                out.push_str(&field.to_lower_camel_case());
828                // Numeric (digit-only) keys index into arrays as integers, not as
829                // string-keyed object properties; emit `[0]` not `["0"]`.
830                if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
831                    out.push_str(&format!("[{key}]"));
832                } else {
833                    out.push_str(&format!("[\"{key}\"]"));
834                }
835            }
836            PathSegment::Length => {
837                out.push_str(".length");
838            }
839        }
840    }
841    out
842}
843
844fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
845    let mut out = result_var.to_string();
846    for seg in segments {
847        match seg {
848            PathSegment::Field(f) => {
849                out.push('.');
850                out.push_str(&f.to_lower_camel_case());
851            }
852            PathSegment::ArrayField { name, index } => {
853                out.push('.');
854                out.push_str(&name.to_lower_camel_case());
855                out.push_str(&format!("[{index}]"));
856            }
857            PathSegment::MapAccess { field, key } => {
858                out.push('.');
859                out.push_str(&field.to_lower_camel_case());
860                out.push_str(&format!(".get(\"{key}\")"));
861            }
862            PathSegment::Length => {
863                out.push_str(".length");
864            }
865        }
866    }
867    out
868}
869
870fn render_go(segments: &[PathSegment], result_var: &str) -> String {
871    let mut out = result_var.to_string();
872    for seg in segments {
873        match seg {
874            PathSegment::Field(f) => {
875                out.push('.');
876                out.push_str(&to_go_name(f));
877            }
878            PathSegment::ArrayField { name, index } => {
879                out.push('.');
880                out.push_str(&to_go_name(name));
881                out.push_str(&format!("[{index}]"));
882            }
883            PathSegment::MapAccess { field, key } => {
884                out.push('.');
885                out.push_str(&to_go_name(field));
886                if key.chars().all(|c| c.is_ascii_digit()) {
887                    out.push_str(&format!("[{key}]"));
888                } else {
889                    out.push_str(&format!("[\"{key}\"]"));
890                }
891            }
892            PathSegment::Length => {
893                let current = std::mem::take(&mut out);
894                out = format!("len({current})");
895            }
896        }
897    }
898    out
899}
900
901fn render_java(segments: &[PathSegment], result_var: &str) -> String {
902    let mut out = result_var.to_string();
903    for seg in segments {
904        match seg {
905            PathSegment::Field(f) => {
906                out.push('.');
907                out.push_str(&f.to_lower_camel_case());
908                out.push_str("()");
909            }
910            PathSegment::ArrayField { name, index } => {
911                out.push('.');
912                out.push_str(&name.to_lower_camel_case());
913                out.push_str(&format!("().get({index})"));
914            }
915            PathSegment::MapAccess { field, key } => {
916                out.push('.');
917                out.push_str(&field.to_lower_camel_case());
918                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
919                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
920                if is_numeric {
921                    out.push_str(&format!("().get({key})"));
922                } else {
923                    out.push_str(&format!("().get(\"{key}\")"));
924                }
925            }
926            PathSegment::Length => {
927                out.push_str(".size()");
928            }
929        }
930    }
931    out
932}
933
934/// Wrap a Kotlin getter name in backticks when it collides with a Kotlin hard keyword.
935///
936/// Hard keywords cannot be used as identifiers without escaping, so `result.object()`
937/// is a syntax error; `` result.`object`() `` is the legal form.
938fn kotlin_getter(name: &str) -> String {
939    let camel = name.to_lower_camel_case();
940    match camel.as_str() {
941        "as" | "break" | "class" | "continue" | "do" | "else" | "false" | "for" | "fun" | "if" | "in" | "interface"
942        | "is" | "null" | "object" | "package" | "return" | "super" | "this" | "throw" | "true" | "try"
943        | "typealias" | "typeof" | "val" | "var" | "when" | "while" => format!("`{camel}`"),
944        _ => camel,
945    }
946}
947
948fn render_kotlin(segments: &[PathSegment], result_var: &str) -> String {
949    let mut out = result_var.to_string();
950    for seg in segments {
951        match seg {
952            PathSegment::Field(f) => {
953                out.push('.');
954                out.push_str(&kotlin_getter(f));
955                out.push_str("()");
956            }
957            PathSegment::ArrayField { name, index } => {
958                out.push('.');
959                out.push_str(&kotlin_getter(name));
960                if *index == 0 {
961                    out.push_str("().first()");
962                } else {
963                    out.push_str(&format!("().get({index})"));
964                }
965            }
966            PathSegment::MapAccess { field, key } => {
967                out.push('.');
968                out.push_str(&kotlin_getter(field));
969                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
970                if is_numeric {
971                    out.push_str(&format!("().get({key})"));
972                } else {
973                    out.push_str(&format!("().get(\"{key}\")"));
974                }
975            }
976            PathSegment::Length => {
977                out.push_str(".size");
978            }
979        }
980    }
981    out
982}
983
984fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
985    let mut out = result_var.to_string();
986    let mut path_so_far = String::new();
987    for (i, seg) in segments.iter().enumerate() {
988        let is_leaf = i == segments.len() - 1;
989        match seg {
990            PathSegment::Field(f) => {
991                if !path_so_far.is_empty() {
992                    path_so_far.push('.');
993                }
994                path_so_far.push_str(f);
995                out.push('.');
996                out.push_str(&f.to_lower_camel_case());
997                out.push_str("()");
998                let _ = is_leaf;
999                let _ = optional_fields;
1000            }
1001            PathSegment::ArrayField { name, index } => {
1002                if !path_so_far.is_empty() {
1003                    path_so_far.push('.');
1004                }
1005                path_so_far.push_str(name);
1006                out.push('.');
1007                out.push_str(&name.to_lower_camel_case());
1008                out.push_str(&format!("().get({index})"));
1009            }
1010            PathSegment::MapAccess { field, key } => {
1011                if !path_so_far.is_empty() {
1012                    path_so_far.push('.');
1013                }
1014                path_so_far.push_str(field);
1015                out.push('.');
1016                out.push_str(&field.to_lower_camel_case());
1017                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1018                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1019                if is_numeric {
1020                    out.push_str(&format!("().get({key})"));
1021                } else {
1022                    out.push_str(&format!("().get(\"{key}\")"));
1023                }
1024            }
1025            PathSegment::Length => {
1026                out.push_str(".size()");
1027            }
1028        }
1029    }
1030    out
1031}
1032
1033/// Kotlin variant of `render_java_with_optionals` using Kotlin idioms.
1034///
1035/// When the previous field in the chain is optional (nullable), uses `?.`
1036/// safe-call navigation for the next segment so the Kotlin compiler is
1037/// satisfied by the nullable receiver.
1038///
1039/// Nullability is **sticky**: once a `?.` safe-call has been emitted for any
1040/// segment, all subsequent segments also use `?.` because they operate on a
1041/// nullable receiver. A non-optional field after a `?.` call still returns
1042/// `T?` (because the whole chain can be null if any prefix was null).
1043///
1044/// Example: for `toolCalls[0].function.name` where `toolCalls` is optional:
1045/// `result.toolCalls()?.first()?.function()?.name()` — even though `function`
1046/// and `name` are themselves non-optional, they follow a `?.` chain.
1047fn render_kotlin_with_optionals(
1048    segments: &[PathSegment],
1049    result_var: &str,
1050    optional_fields: &HashSet<String>,
1051) -> String {
1052    let mut out = result_var.to_string();
1053    let mut path_so_far = String::new();
1054    // Track whether the previous segment returned a nullable type. Starts
1055    // false because `result_var` is always non-null.
1056    //
1057    // This flag is sticky: once set to true it stays true for the rest of
1058    // the chain because a `?.` call returns `T?` regardless of whether the
1059    // subsequent field itself is declared optional. All accesses on a
1060    // nullable receiver must also use `?.`.
1061    let mut prev_was_nullable = false;
1062    for seg in segments {
1063        let nav = if prev_was_nullable { "?." } else { "." };
1064        match seg {
1065            PathSegment::Field(f) => {
1066                if !path_so_far.is_empty() {
1067                    path_so_far.push('.');
1068                }
1069                path_so_far.push_str(f);
1070                // After this call, the receiver is nullable if the field is in
1071                // optional_fields (the Java @Nullable annotation makes the
1072                // return type T? in Kotlin) OR if the incoming receiver was
1073                // already nullable (sticky: `?.` call yields `T?`).
1074                let is_optional = optional_fields.contains(&path_so_far);
1075                out.push_str(nav);
1076                out.push_str(&kotlin_getter(f));
1077                out.push_str("()");
1078                prev_was_nullable = prev_was_nullable || is_optional;
1079            }
1080            PathSegment::ArrayField { name, index } => {
1081                if !path_so_far.is_empty() {
1082                    path_so_far.push('.');
1083                }
1084                path_so_far.push_str(name);
1085                let is_optional = optional_fields.contains(&path_so_far);
1086                out.push_str(nav);
1087                out.push_str(&kotlin_getter(name));
1088                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1089                if *index == 0 {
1090                    out.push_str(&format!("(){safe}.first()"));
1091                } else {
1092                    out.push_str(&format!("(){safe}.get({index})"));
1093                }
1094                // Record the "[0]" suffix so subsequent optional-field checks against
1095                // paths like "choices[0].message.tool_calls" continue to match when the
1096                // optional_fields set uses indexed keys (mirrors the Rust renderer).
1097                path_so_far.push_str("[0]");
1098                prev_was_nullable = prev_was_nullable || is_optional;
1099            }
1100            PathSegment::MapAccess { field, key } => {
1101                if !path_so_far.is_empty() {
1102                    path_so_far.push('.');
1103                }
1104                path_so_far.push_str(field);
1105                let is_optional = optional_fields.contains(&path_so_far);
1106                out.push_str(nav);
1107                out.push_str(&kotlin_getter(field));
1108                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1109                if is_numeric {
1110                    if prev_was_nullable || is_optional {
1111                        out.push_str(&format!("()?.get({key})"));
1112                    } else {
1113                        out.push_str(&format!("().get({key})"));
1114                    }
1115                } else if prev_was_nullable || is_optional {
1116                    out.push_str(&format!("()?.get(\"{key}\")"));
1117                } else {
1118                    out.push_str(&format!("().get(\"{key}\")"));
1119                }
1120                prev_was_nullable = prev_was_nullable || is_optional;
1121            }
1122            PathSegment::Length => {
1123                // .size is a Kotlin property, no () needed.
1124                // If the previous field was nullable, use ?.size
1125                let size_nav = if prev_was_nullable { "?" } else { "" };
1126                out.push_str(&format!("{size_nav}.size"));
1127                prev_was_nullable = false;
1128            }
1129        }
1130    }
1131    out
1132}
1133
1134/// kotlin_android variant of `render_kotlin_with_optionals`.
1135///
1136/// kotlin_android generates Kotlin data classes whose fields are Kotlin
1137/// **properties** (not Java-style getter methods). Every field segment must
1138/// therefore be accessed without parentheses: `result.choices.first().message.content`
1139/// rather than `result.choices().first().message().content()`.
1140///
1141/// The nullable-chain rules are identical to `render_kotlin_with_optionals`:
1142/// once a segment in the path is optional (`T?`) the remainder of the chain
1143/// uses `?.` safe-call syntax.
1144fn render_kotlin_android_with_optionals(
1145    segments: &[PathSegment],
1146    result_var: &str,
1147    optional_fields: &HashSet<String>,
1148) -> String {
1149    let mut out = result_var.to_string();
1150    let mut path_so_far = String::new();
1151    let mut prev_was_nullable = false;
1152    for seg in segments {
1153        let nav = if prev_was_nullable { "?." } else { "." };
1154        match seg {
1155            PathSegment::Field(f) => {
1156                if !path_so_far.is_empty() {
1157                    path_so_far.push('.');
1158                }
1159                path_so_far.push_str(f);
1160                let is_optional = optional_fields.contains(&path_so_far);
1161                out.push_str(nav);
1162                // Property access — no () suffix.
1163                out.push_str(&kotlin_getter(f));
1164                prev_was_nullable = prev_was_nullable || is_optional;
1165            }
1166            PathSegment::ArrayField { name, index } => {
1167                if !path_so_far.is_empty() {
1168                    path_so_far.push('.');
1169                }
1170                path_so_far.push_str(name);
1171                let is_optional = optional_fields.contains(&path_so_far);
1172                out.push_str(nav);
1173                // Property access — no () suffix on the collection itself.
1174                out.push_str(&kotlin_getter(name));
1175                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1176                if *index == 0 {
1177                    out.push_str(&format!("{safe}.first()"));
1178                } else {
1179                    out.push_str(&format!("{safe}.get({index})"));
1180                }
1181                path_so_far.push_str("[0]");
1182                prev_was_nullable = prev_was_nullable || is_optional;
1183            }
1184            PathSegment::MapAccess { field, key } => {
1185                if !path_so_far.is_empty() {
1186                    path_so_far.push('.');
1187                }
1188                path_so_far.push_str(field);
1189                let is_optional = optional_fields.contains(&path_so_far);
1190                out.push_str(nav);
1191                // Property access — no () suffix on the map field.
1192                out.push_str(&kotlin_getter(field));
1193                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1194                if is_numeric {
1195                    if prev_was_nullable || is_optional {
1196                        out.push_str(&format!("?.get({key})"));
1197                    } else {
1198                        out.push_str(&format!(".get({key})"));
1199                    }
1200                } else if prev_was_nullable || is_optional {
1201                    out.push_str(&format!("?.get(\"{key}\")"));
1202                } else {
1203                    out.push_str(&format!(".get(\"{key}\")"));
1204                }
1205                prev_was_nullable = prev_was_nullable || is_optional;
1206            }
1207            PathSegment::Length => {
1208                let size_nav = if prev_was_nullable { "?" } else { "" };
1209                out.push_str(&format!("{size_nav}.size"));
1210                prev_was_nullable = false;
1211            }
1212        }
1213    }
1214    out
1215}
1216
1217/// Non-optional variant of `render_kotlin_android_with_optionals`.
1218///
1219/// Used by `render_accessor` (the path without per-field optionality tracking).
1220fn render_kotlin_android(segments: &[PathSegment], result_var: &str) -> String {
1221    let mut out = result_var.to_string();
1222    for seg in segments {
1223        match seg {
1224            PathSegment::Field(f) => {
1225                out.push('.');
1226                out.push_str(&kotlin_getter(f));
1227                // No () — property access.
1228            }
1229            PathSegment::ArrayField { name, index } => {
1230                out.push('.');
1231                out.push_str(&kotlin_getter(name));
1232                if *index == 0 {
1233                    out.push_str(".first()");
1234                } else {
1235                    out.push_str(&format!(".get({index})"));
1236                }
1237            }
1238            PathSegment::MapAccess { field, key } => {
1239                out.push('.');
1240                out.push_str(&kotlin_getter(field));
1241                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1242                if is_numeric {
1243                    out.push_str(&format!(".get({key})"));
1244                } else {
1245                    out.push_str(&format!(".get(\"{key}\")"));
1246                }
1247            }
1248            PathSegment::Length => {
1249                out.push_str(".size");
1250            }
1251        }
1252    }
1253    out
1254}
1255
1256/// Rust accessor with Option unwrapping for intermediate fields.
1257///
1258/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
1259/// is appended after the field access to unwrap the `Option<T>`.
1260/// When a path is in `method_calls`, `()` is appended to make it a method call.
1261fn render_rust_with_optionals(
1262    segments: &[PathSegment],
1263    result_var: &str,
1264    optional_fields: &HashSet<String>,
1265    method_calls: &HashSet<String>,
1266) -> String {
1267    let mut out = result_var.to_string();
1268    let mut path_so_far = String::new();
1269    for (i, seg) in segments.iter().enumerate() {
1270        let is_leaf = i == segments.len() - 1;
1271        match seg {
1272            PathSegment::Field(f) => {
1273                if !path_so_far.is_empty() {
1274                    path_so_far.push('.');
1275                }
1276                path_so_far.push_str(f);
1277                out.push('.');
1278                out.push_str(&f.to_snake_case());
1279                let is_method = method_calls.contains(&path_so_far);
1280                if is_method {
1281                    out.push_str("()");
1282                    if !is_leaf && optional_fields.contains(&path_so_far) {
1283                        out.push_str(".as_ref().unwrap()");
1284                    }
1285                } else if !is_leaf && optional_fields.contains(&path_so_far) {
1286                    out.push_str(".as_ref().unwrap()");
1287                }
1288            }
1289            PathSegment::ArrayField { name, index } => {
1290                if !path_so_far.is_empty() {
1291                    path_so_far.push('.');
1292                }
1293                path_so_far.push_str(name);
1294                out.push('.');
1295                out.push_str(&name.to_snake_case());
1296                // Option<Vec<T>>: must unwrap the Option before indexing.
1297                // Check both "name" (bare) and "name[0]" (indexed) forms since the
1298                // optional_fields registry may use either convention.
1299                let path_with_idx = format!("{path_so_far}[0]");
1300                let is_opt = optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1301                if is_opt {
1302                    out.push_str(&format!(".as_ref().unwrap()[{index}]"));
1303                } else {
1304                    out.push_str(&format!("[{index}]"));
1305                }
1306                // Record the normalised "[0]" suffix in path_so_far so that deeper
1307                // optional-field keys which include explicit indices (e.g.
1308                // "choices[0].message.tool_calls") continue to match when we check
1309                // subsequent segments.
1310                path_so_far.push_str("[0]");
1311            }
1312            PathSegment::MapAccess { field, key } => {
1313                if !path_so_far.is_empty() {
1314                    path_so_far.push('.');
1315                }
1316                path_so_far.push_str(field);
1317                out.push('.');
1318                out.push_str(&field.to_snake_case());
1319                if key.chars().all(|c| c.is_ascii_digit()) {
1320                    // Check optional both with and without the numeric index suffix.
1321                    let path_with_idx = format!("{path_so_far}[0]");
1322                    let is_opt =
1323                        optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1324                    if is_opt {
1325                        out.push_str(&format!(".as_ref().unwrap()[{key}]"));
1326                    } else {
1327                        out.push_str(&format!("[{key}]"));
1328                    }
1329                    path_so_far.push_str("[0]");
1330                } else {
1331                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
1332                }
1333            }
1334            PathSegment::Length => {
1335                out.push_str(".len()");
1336            }
1337        }
1338    }
1339    out
1340}
1341
1342/// Zig accessor that unwraps optional fields with `.?`.
1343///
1344/// Zig does not allow field access, indexing, or comparisons through `?T`;
1345/// the value must be unwrapped first. Each segment whose path appears in the
1346/// optional-field set is followed by `.?` so the resulting expression is a
1347/// concrete value usable in assertions.
1348///
1349/// Paths in `method_calls` represent tagged-union variant accessors (Rust
1350/// variant getters such as `FormatMetadata::excel()`). In Zig, tagged-union
1351/// variants are accessed via the same dot syntax as struct fields, so the
1352/// segment is emitted as `.{name}` *without* `.?` even if the path also
1353/// appears in `optional_fields`.
1354fn render_zig_with_optionals(
1355    segments: &[PathSegment],
1356    result_var: &str,
1357    optional_fields: &HashSet<String>,
1358    method_calls: &HashSet<String>,
1359) -> String {
1360    let mut out = result_var.to_string();
1361    let mut path_so_far = String::new();
1362    for seg in segments {
1363        match seg {
1364            PathSegment::Field(f) => {
1365                if !path_so_far.is_empty() {
1366                    path_so_far.push('.');
1367                }
1368                path_so_far.push_str(f);
1369                out.push('.');
1370                out.push_str(f);
1371                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1372                    out.push_str(".?");
1373                }
1374            }
1375            PathSegment::ArrayField { name, index } => {
1376                if !path_so_far.is_empty() {
1377                    path_so_far.push('.');
1378                }
1379                path_so_far.push_str(name);
1380                out.push('.');
1381                out.push_str(name);
1382                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1383                    out.push_str(".?");
1384                }
1385                out.push_str(&format!("[{index}]"));
1386            }
1387            PathSegment::MapAccess { field, key } => {
1388                if !path_so_far.is_empty() {
1389                    path_so_far.push('.');
1390                }
1391                path_so_far.push_str(field);
1392                out.push('.');
1393                out.push_str(field);
1394                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1395                    out.push_str(".?");
1396                }
1397                if key.chars().all(|c| c.is_ascii_digit()) {
1398                    out.push_str(&format!("[{key}]"));
1399                } else {
1400                    out.push_str(&format!(".get(\"{key}\")"));
1401                }
1402            }
1403            PathSegment::Length => {
1404                out.push_str(".len");
1405            }
1406        }
1407    }
1408    out
1409}
1410
1411fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
1412    let mut out = result_var.to_string();
1413    for seg in segments {
1414        match seg {
1415            PathSegment::Field(f) => {
1416                out.push('.');
1417                out.push_str(&f.to_pascal_case());
1418            }
1419            PathSegment::ArrayField { name, index } => {
1420                out.push('.');
1421                out.push_str(&name.to_pascal_case());
1422                out.push_str(&format!("[{index}]"));
1423            }
1424            PathSegment::MapAccess { field, key } => {
1425                out.push('.');
1426                out.push_str(&field.to_pascal_case());
1427                if key.chars().all(|c| c.is_ascii_digit()) {
1428                    out.push_str(&format!("[{key}]"));
1429                } else {
1430                    out.push_str(&format!("[\"{key}\"]"));
1431                }
1432            }
1433            PathSegment::Length => {
1434                out.push_str(".Count");
1435            }
1436        }
1437    }
1438    out
1439}
1440
1441fn render_csharp_with_optionals(
1442    segments: &[PathSegment],
1443    result_var: &str,
1444    optional_fields: &HashSet<String>,
1445) -> String {
1446    let mut out = result_var.to_string();
1447    let mut path_so_far = String::new();
1448    for (i, seg) in segments.iter().enumerate() {
1449        let is_leaf = i == segments.len() - 1;
1450        match seg {
1451            PathSegment::Field(f) => {
1452                if !path_so_far.is_empty() {
1453                    path_so_far.push('.');
1454                }
1455                path_so_far.push_str(f);
1456                out.push('.');
1457                out.push_str(&f.to_pascal_case());
1458                if !is_leaf && optional_fields.contains(&path_so_far) {
1459                    out.push('!');
1460                }
1461            }
1462            PathSegment::ArrayField { name, index } => {
1463                if !path_so_far.is_empty() {
1464                    path_so_far.push('.');
1465                }
1466                path_so_far.push_str(name);
1467                out.push('.');
1468                out.push_str(&name.to_pascal_case());
1469                out.push_str(&format!("[{index}]"));
1470            }
1471            PathSegment::MapAccess { field, key } => {
1472                if !path_so_far.is_empty() {
1473                    path_so_far.push('.');
1474                }
1475                path_so_far.push_str(field);
1476                out.push('.');
1477                out.push_str(&field.to_pascal_case());
1478                if key.chars().all(|c| c.is_ascii_digit()) {
1479                    out.push_str(&format!("[{key}]"));
1480                } else {
1481                    out.push_str(&format!("[\"{key}\"]"));
1482                }
1483            }
1484            PathSegment::Length => {
1485                out.push_str(".Count");
1486            }
1487        }
1488    }
1489    out
1490}
1491
1492fn render_php(segments: &[PathSegment], result_var: &str) -> String {
1493    let mut out = result_var.to_string();
1494    for seg in segments {
1495        match seg {
1496            PathSegment::Field(f) => {
1497                out.push_str("->");
1498                // PHP properties are camelCase (per #[php(prop, name = "...")]),
1499                // so convert snake_case field names to camelCase.
1500                out.push_str(&f.to_lower_camel_case());
1501            }
1502            PathSegment::ArrayField { name, index } => {
1503                out.push_str("->");
1504                out.push_str(&name.to_lower_camel_case());
1505                out.push_str(&format!("[{index}]"));
1506            }
1507            PathSegment::MapAccess { field, key } => {
1508                out.push_str("->");
1509                out.push_str(&field.to_lower_camel_case());
1510                out.push_str(&format!("[\"{key}\"]"));
1511            }
1512            PathSegment::Length => {
1513                let current = std::mem::take(&mut out);
1514                out = format!("count({current})");
1515            }
1516        }
1517    }
1518    out
1519}
1520
1521/// PHP accessor that distinguishes between scalar fields (property access: `->camelCase`)
1522/// and non-scalar fields (getter-method access: `->getCamelCase()`).
1523///
1524/// ext-php-rs 0.15.x exposes scalar fields via `#[php(prop)]` as PHP properties, but
1525/// non-scalar fields (Named structs, `Vec<Named>`, `Map`, etc.) require a `#[php(getter)]`
1526/// method because `get_method_props` is `todo!()` in ext-php-rs-derive 0.11.7.
1527/// The generated getter method name is `get{CamelCase}` (stripping the `get_` prefix and
1528/// converting the camelCase remainder to a PHP property name), so e2e assertions must call
1529/// `->getCamelCase()` for those fields.
1530///
1531/// `getter_map` carries the per-`(owner_type, field_name)` classification along with the
1532/// chain-resolution metadata required to walk multi-segment paths through the IR's nested
1533/// type graph. Each path segment is classified using the *current* owner type, then the
1534/// owner cursor advances to the field's referenced `Named` type (if any) for the next
1535/// segment. When `root_type` is unset the renderer falls back to the legacy bare-name
1536/// union, which is unsafe but preserves backwards compatibility for callers that have
1537/// not wired type resolution.
1538fn render_php_with_getters(segments: &[PathSegment], result_var: &str, getter_map: &PhpGetterMap) -> String {
1539    let mut out = result_var.to_string();
1540    let mut current_type: Option<String> = getter_map.root_type.clone();
1541    for seg in segments {
1542        match seg {
1543            PathSegment::Field(f) => {
1544                let camel = f.to_lower_camel_case();
1545                if getter_map.needs_getter(current_type.as_deref(), f.as_str()) {
1546                    // Non-scalar field: ext-php-rs emits a `get{CamelCase}()` method.
1547                    // The `get_` prefix is stripped by ext-php-rs when it derives the
1548                    // PHP property name, but the Rust method ident is `get_{camelCase}`,
1549                    // so the PHP call is `->get{CamelCase}()`.
1550                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1551                    out.push_str("->");
1552                    out.push_str(&getter);
1553                    out.push_str("()");
1554                } else {
1555                    out.push_str("->");
1556                    out.push_str(&camel);
1557                }
1558                current_type = getter_map.advance(current_type.as_deref(), f.as_str());
1559            }
1560            PathSegment::ArrayField { name, index } => {
1561                let camel = name.to_lower_camel_case();
1562                if getter_map.needs_getter(current_type.as_deref(), name.as_str()) {
1563                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1564                    out.push_str("->");
1565                    out.push_str(&getter);
1566                    out.push_str("()");
1567                } else {
1568                    out.push_str("->");
1569                    out.push_str(&camel);
1570                }
1571                out.push_str(&format!("[{index}]"));
1572                current_type = getter_map.advance(current_type.as_deref(), name.as_str());
1573            }
1574            PathSegment::MapAccess { field, key } => {
1575                let camel = field.to_lower_camel_case();
1576                if getter_map.needs_getter(current_type.as_deref(), field.as_str()) {
1577                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1578                    out.push_str("->");
1579                    out.push_str(&getter);
1580                    out.push_str("()");
1581                } else {
1582                    out.push_str("->");
1583                    out.push_str(&camel);
1584                }
1585                out.push_str(&format!("[\"{key}\"]"));
1586                current_type = getter_map.advance(current_type.as_deref(), field.as_str());
1587            }
1588            PathSegment::Length => {
1589                let current = std::mem::take(&mut out);
1590                out = format!("count({current})");
1591            }
1592        }
1593    }
1594    out
1595}
1596
1597fn render_r(segments: &[PathSegment], result_var: &str) -> String {
1598    let mut out = result_var.to_string();
1599    for seg in segments {
1600        match seg {
1601            PathSegment::Field(f) => {
1602                out.push('$');
1603                out.push_str(f);
1604            }
1605            PathSegment::ArrayField { name, index } => {
1606                out.push('$');
1607                out.push_str(name);
1608                // R uses 1-based indexing.
1609                out.push_str(&format!("[[{}]]", index + 1));
1610            }
1611            PathSegment::MapAccess { field, key } => {
1612                out.push('$');
1613                out.push_str(field);
1614                out.push_str(&format!("[[\"{key}\"]]"));
1615            }
1616            PathSegment::Length => {
1617                let current = std::mem::take(&mut out);
1618                out = format!("length({current})");
1619            }
1620        }
1621    }
1622    out
1623}
1624
1625fn render_c(segments: &[PathSegment], result_var: &str) -> String {
1626    let mut parts = Vec::new();
1627    let mut trailing_length = false;
1628    for seg in segments {
1629        match seg {
1630            PathSegment::Field(f) => parts.push(f.to_snake_case()),
1631            PathSegment::ArrayField { name, .. } => parts.push(name.to_snake_case()),
1632            PathSegment::MapAccess { field, key } => {
1633                parts.push(field.to_snake_case());
1634                parts.push(key.clone());
1635            }
1636            PathSegment::Length => {
1637                trailing_length = true;
1638            }
1639        }
1640    }
1641    let suffix = parts.join("_");
1642    if trailing_length {
1643        format!("result_{suffix}_count({result_var})")
1644    } else {
1645        format!("result_{suffix}({result_var})")
1646    }
1647}
1648
1649/// Dart accessor using camelCase field names (FRB v2 convention).
1650///
1651/// FRB v2 generates Dart property getters with camelCase names for every
1652/// snake_case Rust field, so `snake_case_field` becomes `snakeCaseField`.
1653/// Array fields index with `[N]`; map fields use `["key"]` or `[N]` notation.
1654/// Length/count segments use `.length` (Dart `List.length`).
1655fn render_dart(segments: &[PathSegment], result_var: &str) -> String {
1656    let mut out = result_var.to_string();
1657    for seg in segments {
1658        match seg {
1659            PathSegment::Field(f) => {
1660                out.push('.');
1661                out.push_str(&f.to_lower_camel_case());
1662            }
1663            PathSegment::ArrayField { name, index } => {
1664                out.push('.');
1665                out.push_str(&name.to_lower_camel_case());
1666                out.push_str(&format!("[{index}]"));
1667            }
1668            PathSegment::MapAccess { field, key } => {
1669                out.push('.');
1670                out.push_str(&field.to_lower_camel_case());
1671                if key.chars().all(|c| c.is_ascii_digit()) {
1672                    out.push_str(&format!("[{key}]"));
1673                } else {
1674                    out.push_str(&format!("[\"{key}\"]"));
1675                }
1676            }
1677            PathSegment::Length => {
1678                out.push_str(".length");
1679            }
1680        }
1681    }
1682    out
1683}
1684
1685/// Dart accessor with optional-safe navigation using `?.` (FRB v2 convention).
1686///
1687/// When an intermediate field is in `optional_fields`, the next segment uses
1688/// `?.` safe-call navigation instead of `.` to avoid a null-dereference on
1689/// a nullable Dart type.  Field names are camelCase (FRB v2 generation rule).
1690fn render_dart_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1691    let mut out = result_var.to_string();
1692    let mut path_so_far = String::new();
1693    let mut prev_was_nullable = false;
1694    for seg in segments {
1695        let nav = if prev_was_nullable { "?." } else { "." };
1696        match seg {
1697            PathSegment::Field(f) => {
1698                if !path_so_far.is_empty() {
1699                    path_so_far.push('.');
1700                }
1701                path_so_far.push_str(f);
1702                let is_optional = optional_fields.contains(&path_so_far);
1703                out.push_str(nav);
1704                out.push_str(&f.to_lower_camel_case());
1705                prev_was_nullable = is_optional;
1706            }
1707            PathSegment::ArrayField { name, index } => {
1708                if !path_so_far.is_empty() {
1709                    path_so_far.push('.');
1710                }
1711                path_so_far.push_str(name);
1712                let is_optional = optional_fields.contains(&path_so_far);
1713                out.push_str(nav);
1714                out.push_str(&name.to_lower_camel_case());
1715                // FRB models `Option<Vec<T>>` as `List<T>?` — only force-unwrap when the field
1716                // is registered as optional. Adding `!` to a non-nullable receiver is a Dart
1717                // compile-time error ("unnecessary non-null assertion").
1718                if is_optional {
1719                    out.push('!');
1720                }
1721                out.push_str(&format!("[{index}]"));
1722                prev_was_nullable = false;
1723            }
1724            PathSegment::MapAccess { field, key } => {
1725                if !path_so_far.is_empty() {
1726                    path_so_far.push('.');
1727                }
1728                path_so_far.push_str(field);
1729                let is_optional = optional_fields.contains(&path_so_far);
1730                out.push_str(nav);
1731                out.push_str(&field.to_lower_camel_case());
1732                if key.chars().all(|c| c.is_ascii_digit()) {
1733                    out.push_str(&format!("[{key}]"));
1734                } else {
1735                    out.push_str(&format!("[\"{key}\"]"));
1736                }
1737                prev_was_nullable = is_optional;
1738            }
1739            PathSegment::Length => {
1740                // Use `?.length` when the receiver is optional — emitting `.length` against
1741                // a `List<T>?` is a Dart sound-null-safety error.
1742                out.push_str(nav);
1743                out.push_str("length");
1744                prev_was_nullable = false;
1745            }
1746        }
1747    }
1748    out
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753    use super::*;
1754
1755    fn make_resolver() -> FieldResolver {
1756        let mut fields = HashMap::new();
1757        fields.insert("title".to_string(), "metadata.document.title".to_string());
1758        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1759        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
1760        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
1761        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
1762        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
1763        let mut optional = HashSet::new();
1764        optional.insert("metadata.document.title".to_string());
1765        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1766    }
1767
1768    fn make_resolver_with_doc_optional() -> FieldResolver {
1769        let mut fields = HashMap::new();
1770        fields.insert("title".to_string(), "metadata.document.title".to_string());
1771        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1772        let mut optional = HashSet::new();
1773        optional.insert("document".to_string());
1774        optional.insert("metadata.document.title".to_string());
1775        optional.insert("metadata.document".to_string());
1776        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1777    }
1778
1779    #[test]
1780    fn test_resolve_alias() {
1781        let r = make_resolver();
1782        assert_eq!(r.resolve("title"), "metadata.document.title");
1783    }
1784
1785    #[test]
1786    fn test_resolve_passthrough() {
1787        let r = make_resolver();
1788        assert_eq!(r.resolve("content"), "content");
1789    }
1790
1791    #[test]
1792    fn test_is_optional() {
1793        let r = make_resolver();
1794        assert!(r.is_optional("metadata.document.title"));
1795        assert!(!r.is_optional("content"));
1796    }
1797
1798    #[test]
1799    fn test_accessor_rust_struct() {
1800        let r = make_resolver();
1801        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
1802    }
1803
1804    #[test]
1805    fn test_accessor_rust_map() {
1806        let r = make_resolver();
1807        assert_eq!(
1808            r.accessor("tags", "rust", "result"),
1809            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
1810        );
1811    }
1812
1813    #[test]
1814    fn test_accessor_python() {
1815        let r = make_resolver();
1816        assert_eq!(
1817            r.accessor("title", "python", "result"),
1818            "result.metadata.document.title"
1819        );
1820    }
1821
1822    #[test]
1823    fn test_accessor_go() {
1824        let r = make_resolver();
1825        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
1826    }
1827
1828    #[test]
1829    fn test_accessor_go_initialism_fields() {
1830        let mut fields = std::collections::HashMap::new();
1831        fields.insert("content".to_string(), "html".to_string());
1832        fields.insert("link_url".to_string(), "links.url".to_string());
1833        let r = FieldResolver::new(
1834            &fields,
1835            &HashSet::new(),
1836            &HashSet::new(),
1837            &HashSet::new(),
1838            &HashSet::new(),
1839        );
1840        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
1841        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
1842        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
1843        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
1844        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
1845        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
1846        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
1847        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
1848    }
1849
1850    #[test]
1851    fn test_accessor_typescript() {
1852        let r = make_resolver();
1853        assert_eq!(
1854            r.accessor("title", "typescript", "result"),
1855            "result.metadata.document.title"
1856        );
1857    }
1858
1859    #[test]
1860    fn test_accessor_typescript_snake_to_camel() {
1861        let r = make_resolver();
1862        assert_eq!(
1863            r.accessor("og", "typescript", "result"),
1864            "result.metadata.document.openGraph"
1865        );
1866        assert_eq!(
1867            r.accessor("twitter", "typescript", "result"),
1868            "result.metadata.document.twitterCard"
1869        );
1870        assert_eq!(
1871            r.accessor("canonical", "typescript", "result"),
1872            "result.metadata.document.canonicalUrl"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_accessor_typescript_map_snake_to_camel() {
1878        let r = make_resolver();
1879        assert_eq!(
1880            r.accessor("og_tag", "typescript", "result"),
1881            "result.metadata.openGraphTags[\"og_title\"]"
1882        );
1883    }
1884
1885    #[test]
1886    fn test_accessor_typescript_numeric_index_is_unquoted() {
1887        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
1888        // must emit numeric bracket access (`[0]`) not string-keyed access
1889        // (`["0"]`), which would return undefined on arrays.
1890        let mut fields = HashMap::new();
1891        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
1892        let r = FieldResolver::new(
1893            &fields,
1894            &HashSet::new(),
1895            &HashSet::new(),
1896            &HashSet::new(),
1897            &HashSet::new(),
1898        );
1899        assert_eq!(
1900            r.accessor("first_score", "typescript", "result"),
1901            "result.results[0].relevanceScore"
1902        );
1903    }
1904
1905    #[test]
1906    fn test_accessor_node_alias() {
1907        let r = make_resolver();
1908        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
1909    }
1910
1911    #[test]
1912    fn test_accessor_wasm_camel_case() {
1913        let r = make_resolver();
1914        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
1915        assert_eq!(
1916            r.accessor("twitter", "wasm", "result"),
1917            "result.metadata.document.twitterCard"
1918        );
1919        assert_eq!(
1920            r.accessor("canonical", "wasm", "result"),
1921            "result.metadata.document.canonicalUrl"
1922        );
1923    }
1924
1925    #[test]
1926    fn test_accessor_wasm_map_access() {
1927        let r = make_resolver();
1928        assert_eq!(
1929            r.accessor("og_tag", "wasm", "result"),
1930            "result.metadata.openGraphTags.get(\"og_title\")"
1931        );
1932    }
1933
1934    #[test]
1935    fn test_accessor_java() {
1936        let r = make_resolver();
1937        assert_eq!(
1938            r.accessor("title", "java", "result"),
1939            "result.metadata().document().title()"
1940        );
1941    }
1942
1943    #[test]
1944    fn test_accessor_kotlin_uses_kotlin_collection_idioms() {
1945        let mut fields = HashMap::new();
1946        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
1947        fields.insert("node_count".to_string(), "nodes.length".to_string());
1948        let mut arrays = HashSet::new();
1949        arrays.insert("nodes".to_string());
1950        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &arrays, &HashSet::new());
1951        assert_eq!(
1952            r.accessor("first_node_name", "kotlin", "result"),
1953            "result.nodes().first().name()"
1954        );
1955        assert_eq!(r.accessor("node_count", "kotlin", "result"), "result.nodes().size");
1956    }
1957
1958    #[test]
1959    fn test_accessor_kotlin_uses_safe_calls_for_optional_prefixes() {
1960        let r = make_resolver_with_doc_optional();
1961        assert_eq!(
1962            r.accessor("title", "kotlin", "result"),
1963            "result.metadata().document()?.title()"
1964        );
1965    }
1966
1967    #[test]
1968    fn test_accessor_kotlin_uses_safe_calls_for_optional_arrays_and_maps() {
1969        let mut fields = HashMap::new();
1970        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
1971        fields.insert("tag".to_string(), "tags[name]".to_string());
1972        let mut optional = HashSet::new();
1973        optional.insert("nodes".to_string());
1974        optional.insert("tags".to_string());
1975        let mut arrays = HashSet::new();
1976        arrays.insert("nodes".to_string());
1977        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
1978        assert_eq!(
1979            r.accessor("first_node_name", "kotlin", "result"),
1980            "result.nodes()?.first()?.name()"
1981        );
1982        assert_eq!(r.accessor("tag", "kotlin", "result"), "result.tags()?.get(\"name\")");
1983    }
1984
1985    /// Regression: optional-field keys with explicit `[0]` indices (e.g.
1986    /// `"choices[0].message.tool_calls"`) were not matched by
1987    /// `render_kotlin_with_optionals` because `path_so_far` omitted the `[0]`
1988    /// suffix after traversing an ArrayField segment. Fix: append `"[0]"` to
1989    /// `path_so_far` after each ArrayField, mirroring the Rust renderer.
1990    #[test]
1991    fn test_accessor_kotlin_optional_field_after_indexed_array() {
1992        // "choices[0].message.tool_calls" is optional; the path is accessed as
1993        // choices[0].message.tool_calls[0].function.name.
1994        let mut fields = HashMap::new();
1995        fields.insert(
1996            "tool_call_name".to_string(),
1997            "choices[0].message.tool_calls[0].function.name".to_string(),
1998        );
1999        let mut optional = HashSet::new();
2000        optional.insert("choices[0].message.tool_calls".to_string());
2001        let mut arrays = HashSet::new();
2002        arrays.insert("choices".to_string());
2003        arrays.insert("choices[0].message.tool_calls".to_string());
2004        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2005        let expr = r.accessor("tool_call_name", "kotlin", "result");
2006        // toolCalls() is optional so it must use `?.` before `.first()`.
2007        assert!(
2008            expr.contains("toolCalls()?.first()"),
2009            "expected toolCalls()?.first() for optional list, got: {expr}"
2010        );
2011    }
2012
2013    #[test]
2014    fn test_accessor_csharp() {
2015        let r = make_resolver();
2016        assert_eq!(
2017            r.accessor("title", "csharp", "result"),
2018            "result.Metadata.Document.Title"
2019        );
2020    }
2021
2022    #[test]
2023    fn test_accessor_php() {
2024        let r = make_resolver();
2025        assert_eq!(
2026            r.accessor("title", "php", "$result"),
2027            "$result->metadata->document->title"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_accessor_r() {
2033        let r = make_resolver();
2034        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
2035    }
2036
2037    #[test]
2038    fn test_accessor_c() {
2039        let r = make_resolver();
2040        assert_eq!(
2041            r.accessor("title", "c", "result"),
2042            "result_metadata_document_title(result)"
2043        );
2044    }
2045
2046    #[test]
2047    fn test_rust_unwrap_binding() {
2048        let r = make_resolver();
2049        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
2050        assert_eq!(var, "metadata_document_title");
2051        assert!(binding.contains("as_ref().map(|v| v.to_string()).unwrap_or_default()"));
2052    }
2053
2054    #[test]
2055    fn test_rust_unwrap_binding_non_optional() {
2056        let r = make_resolver();
2057        assert!(r.rust_unwrap_binding("content", "result").is_none());
2058    }
2059
2060    #[test]
2061    fn test_rust_unwrap_binding_collapses_double_underscore() {
2062        // When an alias resolves to a path with `[]` (e.g. `json_ld.name` →
2063        // `json_ld[].name`), the naive replace previously yielded `json_ld__name`,
2064        // which trips Rust's non_snake_case lint under -D warnings. The local
2065        // binding name must collapse consecutive underscores into one.
2066        let mut aliases = HashMap::new();
2067        aliases.insert("json_ld.name".to_string(), "json_ld[].name".to_string());
2068        let mut optional = HashSet::new();
2069        optional.insert("json_ld[].name".to_string());
2070        let mut array = HashSet::new();
2071        array.insert("json_ld".to_string());
2072        let result_fields = HashSet::new();
2073        let method_calls = HashSet::new();
2074        let r = FieldResolver::new(&aliases, &optional, &result_fields, &array, &method_calls);
2075        let (_binding, var) = r.rust_unwrap_binding("json_ld.name", "result").unwrap();
2076        assert_eq!(var, "json_ld_name");
2077    }
2078
2079    #[test]
2080    fn test_direct_field_no_alias() {
2081        let r = make_resolver();
2082        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2083        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
2084    }
2085
2086    #[test]
2087    fn test_accessor_rust_with_optionals() {
2088        let r = make_resolver_with_doc_optional();
2089        assert_eq!(
2090            r.accessor("title", "rust", "result"),
2091            "result.metadata.document.as_ref().unwrap().title"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_accessor_csharp_with_optionals() {
2097        let r = make_resolver_with_doc_optional();
2098        assert_eq!(
2099            r.accessor("title", "csharp", "result"),
2100            "result.Metadata.Document!.Title"
2101        );
2102    }
2103
2104    #[test]
2105    fn test_accessor_rust_non_optional_field() {
2106        let r = make_resolver();
2107        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2108    }
2109
2110    #[test]
2111    fn test_accessor_csharp_non_optional_field() {
2112        let r = make_resolver();
2113        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
2114    }
2115
2116    #[test]
2117    fn test_accessor_rust_method_call() {
2118        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
2119        let mut fields = HashMap::new();
2120        fields.insert(
2121            "excel_sheet_count".to_string(),
2122            "metadata.format.excel.sheet_count".to_string(),
2123        );
2124        let mut optional = HashSet::new();
2125        optional.insert("metadata.format".to_string());
2126        optional.insert("metadata.format.excel".to_string());
2127        let mut method_calls = HashSet::new();
2128        method_calls.insert("metadata.format.excel".to_string());
2129        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
2130        assert_eq!(
2131            r.accessor("excel_sheet_count", "rust", "result"),
2132            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
2133        );
2134    }
2135
2136    // ---------------------------------------------------------------------------
2137    // PHP getter-method tests (ext-php-rs 0.15.x `#[php(getter)]` vs `#[php(prop)]`)
2138    // ---------------------------------------------------------------------------
2139
2140    fn make_php_getter_resolver() -> FieldResolver {
2141        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2142        getters.insert(
2143            "Root".to_string(),
2144            ["metadata".to_string(), "links".to_string()].into_iter().collect(),
2145        );
2146        let map = PhpGetterMap {
2147            getters,
2148            field_types: HashMap::new(),
2149            root_type: Some("Root".to_string()),
2150        };
2151        FieldResolver::new_with_php_getters(
2152            &HashMap::new(),
2153            &HashSet::new(),
2154            &HashSet::new(),
2155            &HashSet::new(),
2156            &HashSet::new(),
2157            &HashMap::new(),
2158            map,
2159        )
2160    }
2161
2162    #[test]
2163    fn render_php_uses_getter_method_for_non_scalar_field() {
2164        let r = make_php_getter_resolver();
2165        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->getMetadata()");
2166    }
2167
2168    #[test]
2169    fn render_php_uses_property_for_scalar_field() {
2170        let r = make_php_getter_resolver();
2171        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2172    }
2173
2174    #[test]
2175    fn render_php_nested_non_scalar_uses_getter_then_property() {
2176        let mut fields = HashMap::new();
2177        fields.insert("title".to_string(), "metadata.title".to_string());
2178        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2179        getters.insert("Root".to_string(), ["metadata".to_string()].into_iter().collect());
2180        // No entry for Metadata.title → scalar by default.
2181        getters.insert("Metadata".to_string(), HashSet::new());
2182        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2183        field_types.insert(
2184            "Root".to_string(),
2185            [("metadata".to_string(), "Metadata".to_string())].into_iter().collect(),
2186        );
2187        let map = PhpGetterMap {
2188            getters,
2189            field_types,
2190            root_type: Some("Root".to_string()),
2191        };
2192        let r = FieldResolver::new_with_php_getters(
2193            &fields,
2194            &HashSet::new(),
2195            &HashSet::new(),
2196            &HashSet::new(),
2197            &HashSet::new(),
2198            &HashMap::new(),
2199            map,
2200        );
2201        // `metadata` → `->getMetadata()`, then `title` (scalar on returned object) → `->title`
2202        assert_eq!(r.accessor("title", "php", "$result"), "$result->getMetadata()->title");
2203    }
2204
2205    #[test]
2206    fn render_php_array_field_uses_getter_when_non_scalar() {
2207        let mut fields = HashMap::new();
2208        fields.insert("first_link".to_string(), "links[0]".to_string());
2209        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2210        getters.insert("Root".to_string(), ["links".to_string()].into_iter().collect());
2211        let map = PhpGetterMap {
2212            getters,
2213            field_types: HashMap::new(),
2214            root_type: Some("Root".to_string()),
2215        };
2216        let r = FieldResolver::new_with_php_getters(
2217            &fields,
2218            &HashSet::new(),
2219            &HashSet::new(),
2220            &HashSet::new(),
2221            &HashSet::new(),
2222            &HashMap::new(),
2223            map,
2224        );
2225        assert_eq!(r.accessor("first_link", "php", "$result"), "$result->getLinks()[0]");
2226    }
2227
2228    #[test]
2229    fn render_php_falls_back_to_property_when_getter_fields_empty() {
2230        // With empty php_getter_map the resolver uses the plain `render_php` path,
2231        // which emits `->camelCase` for every field regardless of scalar-ness.
2232        let r = FieldResolver::new(
2233            &HashMap::new(),
2234            &HashSet::new(),
2235            &HashSet::new(),
2236            &HashSet::new(),
2237            &HashSet::new(),
2238        );
2239        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2240        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->metadata");
2241    }
2242
2243    // Regression: bare-name HashSet classification produced false getters when two
2244    // types shared a field name with different scalarness (kreuzcrawl `content`
2245    // collision between CrawlConfig.content: ContentConfig and MarkdownResult.content: String).
2246    #[test]
2247    fn render_php_with_getters_distinguishes_same_field_name_on_different_types() {
2248        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2249        // A.content is non-scalar.
2250        getters.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2251        // B.content is scalar — explicit empty set.
2252        getters.insert("B".to_string(), HashSet::new());
2253        let map_a = PhpGetterMap {
2254            getters: getters.clone(),
2255            field_types: HashMap::new(),
2256            root_type: Some("A".to_string()),
2257        };
2258        let map_b = PhpGetterMap {
2259            getters,
2260            field_types: HashMap::new(),
2261            root_type: Some("B".to_string()),
2262        };
2263        let r_a = FieldResolver::new_with_php_getters(
2264            &HashMap::new(),
2265            &HashSet::new(),
2266            &HashSet::new(),
2267            &HashSet::new(),
2268            &HashSet::new(),
2269            &HashMap::new(),
2270            map_a,
2271        );
2272        let r_b = FieldResolver::new_with_php_getters(
2273            &HashMap::new(),
2274            &HashSet::new(),
2275            &HashSet::new(),
2276            &HashSet::new(),
2277            &HashSet::new(),
2278            &HashMap::new(),
2279            map_b,
2280        );
2281        assert_eq!(r_a.accessor("content", "php", "$a"), "$a->getContent()");
2282        assert_eq!(r_b.accessor("content", "php", "$b"), "$b->content");
2283    }
2284
2285    // Regression: the chain renderer must advance current_type through the IR's
2286    // nested-type graph so a scalar field on a nested type is not falsely
2287    // classified as needing a getter because some other type uses the same name.
2288    #[test]
2289    fn render_php_with_getters_chains_through_correct_type() {
2290        let mut fields = HashMap::new();
2291        fields.insert("nested_content".to_string(), "inner.content".to_string());
2292        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2293        // Outer.inner is non-scalar (struct B).
2294        getters.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2295        // B.content is scalar.
2296        getters.insert("B".to_string(), HashSet::new());
2297        // Decoy: another type with non-scalar `content` field — used to verify
2298        // the legacy bare-name union would have produced the wrong answer.
2299        getters.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2300        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2301        field_types.insert(
2302            "Outer".to_string(),
2303            [("inner".to_string(), "B".to_string())].into_iter().collect(),
2304        );
2305        let map = PhpGetterMap {
2306            getters,
2307            field_types,
2308            root_type: Some("Outer".to_string()),
2309        };
2310        let r = FieldResolver::new_with_php_getters(
2311            &fields,
2312            &HashSet::new(),
2313            &HashSet::new(),
2314            &HashSet::new(),
2315            &HashSet::new(),
2316            &HashMap::new(),
2317            map,
2318        );
2319        assert_eq!(
2320            r.accessor("nested_content", "php", "$result"),
2321            "$result->getInner()->content"
2322        );
2323    }
2324}