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            "rust" => render_rust_with_optionals(&segments, result_var, &self.optional_fields, &self.method_calls),
161            "csharp" => render_csharp_with_optionals(&segments, result_var, &self.optional_fields),
162            "zig" => render_zig_with_optionals(&segments, result_var, &self.optional_fields, &self.method_calls),
163            "swift" => render_swift_with_optionals(&segments, result_var, &self.optional_fields),
164            _ => render_accessor(&segments, language, result_var),
165        }
166    }
167
168    fn inject_array_indexing(&self, segments: Vec<PathSegment>) -> Vec<PathSegment> {
169        if self.array_fields.is_empty() {
170            return segments;
171        }
172        let len = segments.len();
173        let mut result = Vec::with_capacity(len);
174        let mut path_so_far = String::new();
175        for i in 0..len {
176            let seg = &segments[i];
177            match seg {
178                PathSegment::Field(f) => {
179                    if !path_so_far.is_empty() {
180                        path_so_far.push('.');
181                    }
182                    path_so_far.push_str(f);
183                    let next_is_length = i + 1 < len && matches!(segments[i + 1], PathSegment::Length);
184                    if i + 1 < len && self.array_fields.contains(&path_so_far) && !next_is_length {
185                        result.push(PathSegment::ArrayField(f.clone()));
186                    } else {
187                        result.push(seg.clone());
188                    }
189                }
190                _ => {
191                    result.push(seg.clone());
192                }
193            }
194        }
195        result
196    }
197
198    /// Generate a Rust variable binding that unwraps an Optional string field.
199    pub fn rust_unwrap_binding(&self, fixture_field: &str, result_var: &str) -> Option<(String, String)> {
200        let resolved = self.resolve(fixture_field);
201        if !self.is_optional(resolved) {
202            return None;
203        }
204        let segments = parse_path(resolved);
205        let segments = self.inject_array_indexing(segments);
206        let local_var = resolved.replace(['.', '['], "_").replace(']', "");
207        let accessor = render_accessor(&segments, "rust", result_var);
208        let has_map_access = segments.iter().any(|s| {
209            if let PathSegment::MapAccess { key, .. } = s {
210                !key.chars().all(|c| c.is_ascii_digit())
211            } else {
212                false
213            }
214        });
215        let is_array = self.is_array(resolved);
216        let binding = if has_map_access {
217            format!("let {local_var} = {accessor}.unwrap_or(\"\");")
218        } else if is_array {
219            format!("let {local_var} = {accessor}.as_deref().unwrap_or(&[]);")
220        } else {
221            format!("let {local_var} = {accessor}.as_ref().map(|v| v.to_string()).unwrap_or_default();")
222        };
223        Some((binding, local_var))
224    }
225}
226
227fn normalize_numeric_indices(path: &str) -> String {
228    let mut result = String::with_capacity(path.len());
229    let mut chars = path.chars().peekable();
230    while let Some(c) = chars.next() {
231        if c == '[' {
232            let mut key = String::new();
233            let mut closed = false;
234            for inner in chars.by_ref() {
235                if inner == ']' {
236                    closed = true;
237                    break;
238                }
239                key.push(inner);
240            }
241            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
242                result.push_str("[0]");
243            } else {
244                result.push('[');
245                result.push_str(&key);
246                if closed {
247                    result.push(']');
248                }
249            }
250        } else {
251            result.push(c);
252        }
253    }
254    result
255}
256
257fn parse_path(path: &str) -> Vec<PathSegment> {
258    let mut segments = Vec::new();
259    for part in path.split('.') {
260        if part == "length" || part == "count" || part == "size" {
261            segments.push(PathSegment::Length);
262        } else if let Some(bracket_pos) = part.find('[') {
263            let field = part[..bracket_pos].to_string();
264            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
265            if key.is_empty() {
266                segments.push(PathSegment::ArrayField(field));
267            } else {
268                segments.push(PathSegment::MapAccess { field, key });
269            }
270        } else {
271            segments.push(PathSegment::Field(part.to_string()));
272        }
273    }
274    segments
275}
276
277fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
278    match language {
279        "rust" => render_rust(segments, result_var),
280        "python" => render_dot_access(segments, result_var, "python"),
281        "typescript" | "node" => render_typescript(segments, result_var),
282        "wasm" => render_wasm(segments, result_var),
283        "go" => render_go(segments, result_var),
284        "java" => render_java(segments, result_var),
285        "csharp" => render_pascal_dot(segments, result_var),
286        "ruby" => render_dot_access(segments, result_var, "ruby"),
287        "php" => render_php(segments, result_var),
288        "elixir" => render_dot_access(segments, result_var, "elixir"),
289        "r" => render_r(segments, result_var),
290        "c" => render_c(segments, result_var),
291        "swift" => render_swift(segments, result_var),
292        _ => render_dot_access(segments, result_var, language),
293    }
294}
295
296/// Generate a Swift accessor expression.
297///
298/// Swift-bridge exposes all Rust struct fields as methods with `()`, so every
299/// field segment must be followed by `()`. Array fields (e.g. `nodes` inside
300/// an array parent) also need `()`.
301fn render_swift(segments: &[PathSegment], result_var: &str) -> String {
302    let mut out = result_var.to_string();
303    for seg in segments {
304        match seg {
305            PathSegment::Field(f) => {
306                out.push('.');
307                out.push_str(f);
308                out.push_str("()");
309            }
310            PathSegment::ArrayField(f) => {
311                out.push('.');
312                out.push_str(f);
313                out.push_str("()[0]");
314            }
315            PathSegment::MapAccess { field, key } => {
316                out.push('.');
317                out.push_str(field);
318                if key.chars().all(|c| c.is_ascii_digit()) {
319                    out.push_str(&format!("()[{key}]"));
320                } else {
321                    out.push_str(&format!("()[\"{key}\"]"));
322                }
323            }
324            PathSegment::Length => {
325                out.push_str(".count");
326            }
327        }
328    }
329    out
330}
331
332/// Generate a Swift accessor expression with optional chaining.
333///
334/// When an intermediate field is in `optional_fields`, a `?` is inserted after the
335/// `()` call on that segment so the next access uses `?.`. This prevents compile
336/// errors when accessing members through an `Optional<T>` in Swift.
337///
338/// Example: for `metadata.format.excel.sheet_count` where `metadata.format` and
339/// `metadata.format.excel` are optional, the result is:
340/// `result.metadata().format()?.excel()?.sheet_count()`
341fn render_swift_with_optionals(
342    segments: &[PathSegment],
343    result_var: &str,
344    optional_fields: &HashSet<String>,
345) -> String {
346    let mut out = result_var.to_string();
347    let mut path_so_far = String::new();
348    let total = segments.len();
349    for (i, seg) in segments.iter().enumerate() {
350        let is_leaf = i == total - 1;
351        match seg {
352            PathSegment::Field(f) => {
353                if !path_so_far.is_empty() {
354                    path_so_far.push('.');
355                }
356                path_so_far.push_str(f);
357                out.push('.');
358                out.push_str(f);
359                out.push_str("()");
360                // Insert `?` after `()` for non-leaf optional fields so the next
361                // member access becomes `?.`.
362                if !is_leaf && optional_fields.contains(&path_so_far) {
363                    out.push('?');
364                }
365            }
366            PathSegment::ArrayField(f) => {
367                if !path_so_far.is_empty() {
368                    path_so_far.push('.');
369                }
370                path_so_far.push_str(f);
371                out.push('.');
372                out.push_str(f);
373                out.push_str("()[0]");
374            }
375            PathSegment::MapAccess { field, key } => {
376                if !path_so_far.is_empty() {
377                    path_so_far.push('.');
378                }
379                path_so_far.push_str(field);
380                out.push('.');
381                out.push_str(field);
382                if key.chars().all(|c| c.is_ascii_digit()) {
383                    out.push_str(&format!("()[{key}]"));
384                } else {
385                    out.push_str(&format!("()[\"{key}\"]"));
386                }
387            }
388            PathSegment::Length => {
389                out.push_str(".count");
390            }
391        }
392    }
393    out
394}
395
396fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
397    let mut out = result_var.to_string();
398    for seg in segments {
399        match seg {
400            PathSegment::Field(f) => {
401                out.push('.');
402                out.push_str(&f.to_snake_case());
403            }
404            PathSegment::ArrayField(f) => {
405                out.push('.');
406                out.push_str(&f.to_snake_case());
407                out.push_str("[0]");
408            }
409            PathSegment::MapAccess { field, key } => {
410                out.push('.');
411                out.push_str(&field.to_snake_case());
412                if key.chars().all(|c| c.is_ascii_digit()) {
413                    out.push_str(&format!("[{key}]"));
414                } else {
415                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
416                }
417            }
418            PathSegment::Length => {
419                out.push_str(".len()");
420            }
421        }
422    }
423    out
424}
425
426fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
427    let mut out = result_var.to_string();
428    for seg in segments {
429        match seg {
430            PathSegment::Field(f) => {
431                out.push('.');
432                out.push_str(f);
433            }
434            PathSegment::ArrayField(f) => {
435                if language == "elixir" {
436                    let current = std::mem::take(&mut out);
437                    out = format!("Enum.at({current}.{f}, 0)");
438                } else {
439                    out.push('.');
440                    out.push_str(f);
441                    out.push_str("[0]");
442                }
443            }
444            PathSegment::MapAccess { field, key } => {
445                let is_numeric = key.chars().all(|c| c.is_ascii_digit());
446                if is_numeric && language == "elixir" {
447                    let current = std::mem::take(&mut out);
448                    out = format!("Enum.at({current}.{field}, {key})");
449                } else {
450                    out.push('.');
451                    out.push_str(field);
452                    if is_numeric {
453                        let idx: usize = key.parse().unwrap_or(0);
454                        out.push_str(&format!("[{idx}]"));
455                    } else if language == "elixir" {
456                        out.push_str(&format!("[\"{key}\"]"));
457                    } else {
458                        out.push_str(&format!(".get(\"{key}\")"));
459                    }
460                }
461            }
462            PathSegment::Length => match language {
463                "ruby" => out.push_str(".length"),
464                "elixir" => {
465                    let current = std::mem::take(&mut out);
466                    out = format!("length({current})");
467                }
468                _ => {
469                    let current = std::mem::take(&mut out);
470                    out = format!("len({current})");
471                }
472            },
473        }
474    }
475    out
476}
477
478fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
479    let mut out = result_var.to_string();
480    for seg in segments {
481        match seg {
482            PathSegment::Field(f) => {
483                out.push('.');
484                out.push_str(&f.to_lower_camel_case());
485            }
486            PathSegment::ArrayField(f) => {
487                out.push('.');
488                out.push_str(&f.to_lower_camel_case());
489                out.push_str("[0]");
490            }
491            PathSegment::MapAccess { field, key } => {
492                out.push('.');
493                out.push_str(&field.to_lower_camel_case());
494                // Numeric (digit-only) keys index into arrays as integers, not as
495                // string-keyed object properties; emit `[0]` not `["0"]`.
496                if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
497                    out.push_str(&format!("[{key}]"));
498                } else {
499                    out.push_str(&format!("[\"{key}\"]"));
500                }
501            }
502            PathSegment::Length => {
503                out.push_str(".length");
504            }
505        }
506    }
507    out
508}
509
510fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
511    let mut out = result_var.to_string();
512    for seg in segments {
513        match seg {
514            PathSegment::Field(f) => {
515                out.push('.');
516                out.push_str(&f.to_lower_camel_case());
517            }
518            PathSegment::ArrayField(f) => {
519                out.push('.');
520                out.push_str(&f.to_lower_camel_case());
521                out.push_str("[0]");
522            }
523            PathSegment::MapAccess { field, key } => {
524                out.push('.');
525                out.push_str(&field.to_lower_camel_case());
526                out.push_str(&format!(".get(\"{key}\")"));
527            }
528            PathSegment::Length => {
529                out.push_str(".length");
530            }
531        }
532    }
533    out
534}
535
536fn render_go(segments: &[PathSegment], result_var: &str) -> String {
537    let mut out = result_var.to_string();
538    for seg in segments {
539        match seg {
540            PathSegment::Field(f) => {
541                out.push('.');
542                out.push_str(&to_go_name(f));
543            }
544            PathSegment::ArrayField(f) => {
545                out.push('.');
546                out.push_str(&to_go_name(f));
547                out.push_str("[0]");
548            }
549            PathSegment::MapAccess { field, key } => {
550                out.push('.');
551                out.push_str(&to_go_name(field));
552                if key.chars().all(|c| c.is_ascii_digit()) {
553                    out.push_str(&format!("[{key}]"));
554                } else {
555                    out.push_str(&format!("[\"{key}\"]"));
556                }
557            }
558            PathSegment::Length => {
559                let current = std::mem::take(&mut out);
560                out = format!("len({current})");
561            }
562        }
563    }
564    out
565}
566
567fn render_java(segments: &[PathSegment], result_var: &str) -> String {
568    let mut out = result_var.to_string();
569    for seg in segments {
570        match seg {
571            PathSegment::Field(f) => {
572                out.push('.');
573                out.push_str(&f.to_lower_camel_case());
574                out.push_str("()");
575            }
576            PathSegment::ArrayField(f) => {
577                out.push('.');
578                out.push_str(&f.to_lower_camel_case());
579                out.push_str("().getFirst()");
580            }
581            PathSegment::MapAccess { field, key } => {
582                out.push('.');
583                out.push_str(&field.to_lower_camel_case());
584                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
585                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
586                if is_numeric {
587                    out.push_str(&format!("().get({key})"));
588                } else {
589                    out.push_str(&format!("().get(\"{key}\")"));
590                }
591            }
592            PathSegment::Length => {
593                out.push_str(".size()");
594            }
595        }
596    }
597    out
598}
599
600fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
601    let mut out = result_var.to_string();
602    let mut path_so_far = String::new();
603    for (i, seg) in segments.iter().enumerate() {
604        let is_leaf = i == segments.len() - 1;
605        match seg {
606            PathSegment::Field(f) => {
607                if !path_so_far.is_empty() {
608                    path_so_far.push('.');
609                }
610                path_so_far.push_str(f);
611                out.push('.');
612                out.push_str(&f.to_lower_camel_case());
613                out.push_str("()");
614                let _ = is_leaf;
615                let _ = optional_fields;
616            }
617            PathSegment::ArrayField(f) => {
618                if !path_so_far.is_empty() {
619                    path_so_far.push('.');
620                }
621                path_so_far.push_str(f);
622                out.push('.');
623                out.push_str(&f.to_lower_camel_case());
624                out.push_str("().getFirst()");
625            }
626            PathSegment::MapAccess { field, key } => {
627                if !path_so_far.is_empty() {
628                    path_so_far.push('.');
629                }
630                path_so_far.push_str(field);
631                out.push('.');
632                out.push_str(&field.to_lower_camel_case());
633                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
634                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
635                if is_numeric {
636                    out.push_str(&format!("().get({key})"));
637                } else {
638                    out.push_str(&format!("().get(\"{key}\")"));
639                }
640            }
641            PathSegment::Length => {
642                out.push_str(".size()");
643            }
644        }
645    }
646    out
647}
648
649/// Rust accessor with Option unwrapping for intermediate fields.
650///
651/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
652/// is appended after the field access to unwrap the `Option<T>`.
653/// When a path is in `method_calls`, `()` is appended to make it a method call.
654fn render_rust_with_optionals(
655    segments: &[PathSegment],
656    result_var: &str,
657    optional_fields: &HashSet<String>,
658    method_calls: &HashSet<String>,
659) -> String {
660    let mut out = result_var.to_string();
661    let mut path_so_far = String::new();
662    for (i, seg) in segments.iter().enumerate() {
663        let is_leaf = i == segments.len() - 1;
664        match seg {
665            PathSegment::Field(f) => {
666                if !path_so_far.is_empty() {
667                    path_so_far.push('.');
668                }
669                path_so_far.push_str(f);
670                out.push('.');
671                out.push_str(&f.to_snake_case());
672                let is_method = method_calls.contains(&path_so_far);
673                if is_method {
674                    out.push_str("()");
675                    if !is_leaf && optional_fields.contains(&path_so_far) {
676                        out.push_str(".as_ref().unwrap()");
677                    }
678                } else if !is_leaf && optional_fields.contains(&path_so_far) {
679                    out.push_str(".as_ref().unwrap()");
680                }
681            }
682            PathSegment::ArrayField(f) => {
683                if !path_so_far.is_empty() {
684                    path_so_far.push('.');
685                }
686                path_so_far.push_str(f);
687                out.push('.');
688                out.push_str(&f.to_snake_case());
689                out.push_str("[0]");
690            }
691            PathSegment::MapAccess { field, key } => {
692                if !path_so_far.is_empty() {
693                    path_so_far.push('.');
694                }
695                path_so_far.push_str(field);
696                out.push('.');
697                out.push_str(&field.to_snake_case());
698                if key.chars().all(|c| c.is_ascii_digit()) {
699                    let is_opt = optional_fields.contains(&path_so_far);
700                    if is_opt {
701                        out.push_str(&format!(".as_ref().unwrap()[{key}]"));
702                    } else {
703                        out.push_str(&format!("[{key}]"));
704                    }
705                    path_so_far.push_str("[0]");
706                } else {
707                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
708                }
709            }
710            PathSegment::Length => {
711                out.push_str(".len()");
712            }
713        }
714    }
715    out
716}
717
718/// Zig accessor that unwraps optional fields with `.?`.
719///
720/// Zig does not allow field access, indexing, or comparisons through `?T`;
721/// the value must be unwrapped first. Each segment whose path appears in the
722/// optional-field set is followed by `.?` so the resulting expression is a
723/// concrete value usable in assertions.
724///
725/// Paths in `method_calls` represent tagged-union variant accessors (Rust
726/// variant getters such as `FormatMetadata::excel()`). In Zig, tagged-union
727/// variants are accessed via the same dot syntax as struct fields, so the
728/// segment is emitted as `.{name}` *without* `.?` even if the path also
729/// appears in `optional_fields`.
730fn render_zig_with_optionals(
731    segments: &[PathSegment],
732    result_var: &str,
733    optional_fields: &HashSet<String>,
734    method_calls: &HashSet<String>,
735) -> String {
736    let mut out = result_var.to_string();
737    let mut path_so_far = String::new();
738    for seg in segments {
739        match seg {
740            PathSegment::Field(f) => {
741                if !path_so_far.is_empty() {
742                    path_so_far.push('.');
743                }
744                path_so_far.push_str(f);
745                out.push('.');
746                out.push_str(f);
747                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
748                    out.push_str(".?");
749                }
750            }
751            PathSegment::ArrayField(f) => {
752                if !path_so_far.is_empty() {
753                    path_so_far.push('.');
754                }
755                path_so_far.push_str(f);
756                out.push('.');
757                out.push_str(f);
758                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
759                    out.push_str(".?");
760                }
761                out.push_str("[0]");
762            }
763            PathSegment::MapAccess { field, key } => {
764                if !path_so_far.is_empty() {
765                    path_so_far.push('.');
766                }
767                path_so_far.push_str(field);
768                out.push('.');
769                out.push_str(field);
770                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
771                    out.push_str(".?");
772                }
773                if key.chars().all(|c| c.is_ascii_digit()) {
774                    out.push_str(&format!("[{key}]"));
775                } else {
776                    out.push_str(&format!(".get(\"{key}\")"));
777                }
778            }
779            PathSegment::Length => {
780                out.push_str(".len");
781            }
782        }
783    }
784    out
785}
786
787fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
788    let mut out = result_var.to_string();
789    for seg in segments {
790        match seg {
791            PathSegment::Field(f) => {
792                out.push('.');
793                out.push_str(&f.to_pascal_case());
794            }
795            PathSegment::ArrayField(f) => {
796                out.push('.');
797                out.push_str(&f.to_pascal_case());
798                out.push_str("[0]");
799            }
800            PathSegment::MapAccess { field, key } => {
801                out.push('.');
802                out.push_str(&field.to_pascal_case());
803                if key.chars().all(|c| c.is_ascii_digit()) {
804                    out.push_str(&format!("[{key}]"));
805                } else {
806                    out.push_str(&format!("[\"{key}\"]"));
807                }
808            }
809            PathSegment::Length => {
810                out.push_str(".Count");
811            }
812        }
813    }
814    out
815}
816
817fn render_csharp_with_optionals(
818    segments: &[PathSegment],
819    result_var: &str,
820    optional_fields: &HashSet<String>,
821) -> String {
822    let mut out = result_var.to_string();
823    let mut path_so_far = String::new();
824    for (i, seg) in segments.iter().enumerate() {
825        let is_leaf = i == segments.len() - 1;
826        match seg {
827            PathSegment::Field(f) => {
828                if !path_so_far.is_empty() {
829                    path_so_far.push('.');
830                }
831                path_so_far.push_str(f);
832                out.push('.');
833                out.push_str(&f.to_pascal_case());
834                if !is_leaf && optional_fields.contains(&path_so_far) {
835                    out.push('!');
836                }
837            }
838            PathSegment::ArrayField(f) => {
839                if !path_so_far.is_empty() {
840                    path_so_far.push('.');
841                }
842                path_so_far.push_str(f);
843                out.push('.');
844                out.push_str(&f.to_pascal_case());
845                out.push_str("[0]");
846            }
847            PathSegment::MapAccess { field, key } => {
848                if !path_so_far.is_empty() {
849                    path_so_far.push('.');
850                }
851                path_so_far.push_str(field);
852                out.push('.');
853                out.push_str(&field.to_pascal_case());
854                if key.chars().all(|c| c.is_ascii_digit()) {
855                    out.push_str(&format!("[{key}]"));
856                } else {
857                    out.push_str(&format!("[\"{key}\"]"));
858                }
859            }
860            PathSegment::Length => {
861                out.push_str(".Count");
862            }
863        }
864    }
865    out
866}
867
868fn render_php(segments: &[PathSegment], result_var: &str) -> String {
869    let mut out = result_var.to_string();
870    for seg in segments {
871        match seg {
872            PathSegment::Field(f) => {
873                out.push_str("->");
874                // PHP properties are camelCase (per #[php(prop, name = "...")]),
875                // so convert snake_case field names to camelCase.
876                out.push_str(&f.to_lower_camel_case());
877            }
878            PathSegment::ArrayField(f) => {
879                out.push_str("->");
880                out.push_str(&f.to_lower_camel_case());
881                out.push_str("[0]");
882            }
883            PathSegment::MapAccess { field, key } => {
884                out.push_str("->");
885                out.push_str(&field.to_lower_camel_case());
886                out.push_str(&format!("[\"{key}\"]"));
887            }
888            PathSegment::Length => {
889                let current = std::mem::take(&mut out);
890                out = format!("count({current})");
891            }
892        }
893    }
894    out
895}
896
897fn render_r(segments: &[PathSegment], result_var: &str) -> String {
898    let mut out = result_var.to_string();
899    for seg in segments {
900        match seg {
901            PathSegment::Field(f) => {
902                out.push('$');
903                out.push_str(f);
904            }
905            PathSegment::ArrayField(f) => {
906                out.push('$');
907                out.push_str(f);
908                out.push_str("[[1]]");
909            }
910            PathSegment::MapAccess { field, key } => {
911                out.push('$');
912                out.push_str(field);
913                out.push_str(&format!("[[\"{key}\"]]"));
914            }
915            PathSegment::Length => {
916                let current = std::mem::take(&mut out);
917                out = format!("length({current})");
918            }
919        }
920    }
921    out
922}
923
924fn render_c(segments: &[PathSegment], result_var: &str) -> String {
925    let mut parts = Vec::new();
926    let mut trailing_length = false;
927    for seg in segments {
928        match seg {
929            PathSegment::Field(f) | PathSegment::ArrayField(f) => parts.push(f.to_snake_case()),
930            PathSegment::MapAccess { field, key } => {
931                parts.push(field.to_snake_case());
932                parts.push(key.clone());
933            }
934            PathSegment::Length => {
935                trailing_length = true;
936            }
937        }
938    }
939    let suffix = parts.join("_");
940    if trailing_length {
941        format!("result_{suffix}_count({result_var})")
942    } else {
943        format!("result_{suffix}({result_var})")
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    fn make_resolver() -> FieldResolver {
952        let mut fields = HashMap::new();
953        fields.insert("title".to_string(), "metadata.document.title".to_string());
954        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
955        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
956        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
957        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
958        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
959        let mut optional = HashSet::new();
960        optional.insert("metadata.document.title".to_string());
961        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
962    }
963
964    fn make_resolver_with_doc_optional() -> FieldResolver {
965        let mut fields = HashMap::new();
966        fields.insert("title".to_string(), "metadata.document.title".to_string());
967        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
968        let mut optional = HashSet::new();
969        optional.insert("document".to_string());
970        optional.insert("metadata.document.title".to_string());
971        optional.insert("metadata.document".to_string());
972        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
973    }
974
975    #[test]
976    fn test_resolve_alias() {
977        let r = make_resolver();
978        assert_eq!(r.resolve("title"), "metadata.document.title");
979    }
980
981    #[test]
982    fn test_resolve_passthrough() {
983        let r = make_resolver();
984        assert_eq!(r.resolve("content"), "content");
985    }
986
987    #[test]
988    fn test_is_optional() {
989        let r = make_resolver();
990        assert!(r.is_optional("metadata.document.title"));
991        assert!(!r.is_optional("content"));
992    }
993
994    #[test]
995    fn test_accessor_rust_struct() {
996        let r = make_resolver();
997        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
998    }
999
1000    #[test]
1001    fn test_accessor_rust_map() {
1002        let r = make_resolver();
1003        assert_eq!(
1004            r.accessor("tags", "rust", "result"),
1005            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_accessor_python() {
1011        let r = make_resolver();
1012        assert_eq!(
1013            r.accessor("title", "python", "result"),
1014            "result.metadata.document.title"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_accessor_go() {
1020        let r = make_resolver();
1021        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
1022    }
1023
1024    #[test]
1025    fn test_accessor_go_initialism_fields() {
1026        let mut fields = std::collections::HashMap::new();
1027        fields.insert("content".to_string(), "html".to_string());
1028        fields.insert("link_url".to_string(), "links.url".to_string());
1029        let r = FieldResolver::new(
1030            &fields,
1031            &HashSet::new(),
1032            &HashSet::new(),
1033            &HashSet::new(),
1034            &HashSet::new(),
1035        );
1036        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
1037        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
1038        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
1039        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
1040        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
1041        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
1042        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
1043        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
1044    }
1045
1046    #[test]
1047    fn test_accessor_typescript() {
1048        let r = make_resolver();
1049        assert_eq!(
1050            r.accessor("title", "typescript", "result"),
1051            "result.metadata.document.title"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_accessor_typescript_snake_to_camel() {
1057        let r = make_resolver();
1058        assert_eq!(
1059            r.accessor("og", "typescript", "result"),
1060            "result.metadata.document.openGraph"
1061        );
1062        assert_eq!(
1063            r.accessor("twitter", "typescript", "result"),
1064            "result.metadata.document.twitterCard"
1065        );
1066        assert_eq!(
1067            r.accessor("canonical", "typescript", "result"),
1068            "result.metadata.document.canonicalUrl"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_accessor_typescript_map_snake_to_camel() {
1074        let r = make_resolver();
1075        assert_eq!(
1076            r.accessor("og_tag", "typescript", "result"),
1077            "result.metadata.openGraphTags[\"og_title\"]"
1078        );
1079    }
1080
1081    #[test]
1082    fn test_accessor_typescript_numeric_index_is_unquoted() {
1083        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
1084        // must emit numeric bracket access (`[0]`) not string-keyed access
1085        // (`["0"]`), which would return undefined on arrays.
1086        let mut fields = HashMap::new();
1087        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
1088        let r = FieldResolver::new(
1089            &fields,
1090            &HashSet::new(),
1091            &HashSet::new(),
1092            &HashSet::new(),
1093            &HashSet::new(),
1094        );
1095        assert_eq!(
1096            r.accessor("first_score", "typescript", "result"),
1097            "result.results[0].relevanceScore"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_accessor_node_alias() {
1103        let r = make_resolver();
1104        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
1105    }
1106
1107    #[test]
1108    fn test_accessor_wasm_camel_case() {
1109        let r = make_resolver();
1110        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
1111        assert_eq!(
1112            r.accessor("twitter", "wasm", "result"),
1113            "result.metadata.document.twitterCard"
1114        );
1115        assert_eq!(
1116            r.accessor("canonical", "wasm", "result"),
1117            "result.metadata.document.canonicalUrl"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_accessor_wasm_map_access() {
1123        let r = make_resolver();
1124        assert_eq!(
1125            r.accessor("og_tag", "wasm", "result"),
1126            "result.metadata.openGraphTags.get(\"og_title\")"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_accessor_java() {
1132        let r = make_resolver();
1133        assert_eq!(
1134            r.accessor("title", "java", "result"),
1135            "result.metadata().document().title()"
1136        );
1137    }
1138
1139    #[test]
1140    fn test_accessor_csharp() {
1141        let r = make_resolver();
1142        assert_eq!(
1143            r.accessor("title", "csharp", "result"),
1144            "result.Metadata.Document.Title"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_accessor_php() {
1150        let r = make_resolver();
1151        assert_eq!(
1152            r.accessor("title", "php", "$result"),
1153            "$result->metadata->document->title"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_accessor_r() {
1159        let r = make_resolver();
1160        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
1161    }
1162
1163    #[test]
1164    fn test_accessor_c() {
1165        let r = make_resolver();
1166        assert_eq!(
1167            r.accessor("title", "c", "result"),
1168            "result_metadata_document_title(result)"
1169        );
1170    }
1171
1172    #[test]
1173    fn test_rust_unwrap_binding() {
1174        let r = make_resolver();
1175        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
1176        assert_eq!(var, "metadata_document_title");
1177        assert!(binding.contains("as_ref().map(|v| v.to_string()).unwrap_or_default()"));
1178    }
1179
1180    #[test]
1181    fn test_rust_unwrap_binding_non_optional() {
1182        let r = make_resolver();
1183        assert!(r.rust_unwrap_binding("content", "result").is_none());
1184    }
1185
1186    #[test]
1187    fn test_direct_field_no_alias() {
1188        let r = make_resolver();
1189        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
1190        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
1191    }
1192
1193    #[test]
1194    fn test_accessor_rust_with_optionals() {
1195        let r = make_resolver_with_doc_optional();
1196        assert_eq!(
1197            r.accessor("title", "rust", "result"),
1198            "result.metadata.document.as_ref().unwrap().title"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_accessor_csharp_with_optionals() {
1204        let r = make_resolver_with_doc_optional();
1205        assert_eq!(
1206            r.accessor("title", "csharp", "result"),
1207            "result.Metadata.Document!.Title"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_accessor_rust_non_optional_field() {
1213        let r = make_resolver();
1214        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
1215    }
1216
1217    #[test]
1218    fn test_accessor_csharp_non_optional_field() {
1219        let r = make_resolver();
1220        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
1221    }
1222
1223    #[test]
1224    fn test_accessor_rust_method_call() {
1225        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
1226        let mut fields = HashMap::new();
1227        fields.insert(
1228            "excel_sheet_count".to_string(),
1229            "metadata.format.excel.sheet_count".to_string(),
1230        );
1231        let mut optional = HashSet::new();
1232        optional.insert("metadata.format".to_string());
1233        optional.insert("metadata.format.excel".to_string());
1234        let mut method_calls = HashSet::new();
1235        method_calls.insert("metadata.format.excel".to_string());
1236        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
1237        assert_eq!(
1238            r.accessor("excel_sheet_count", "rust", "result"),
1239            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
1240        );
1241    }
1242}