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