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