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