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