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