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