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