Skip to main content

alef_e2e/
field_access.rs

1//! Field path resolution for nested struct/map access in e2e assertions.
2//!
3//! The `FieldResolver` maps fixture field paths (e.g., "metadata.title") to
4//! actual API struct paths (e.g., "metadata.document.title") and generates
5//! language-specific accessor expressions.
6
7use alef_codegen::naming::to_go_name;
8use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
9use std::collections::{HashMap, HashSet};
10
11/// Resolves fixture field paths to language-specific accessor expressions.
12pub struct FieldResolver {
13    aliases: HashMap<String, String>,
14    optional_fields: HashSet<String>,
15    result_fields: HashSet<String>,
16    array_fields: HashSet<String>,
17    method_calls: HashSet<String>,
18    /// Aliases for error-path field access (used when assertion_type == "error").
19    /// Maps fixture sub-field names (the part after "error.") to actual field names
20    /// on the error type. E.g., `"status_code" -> "status_code"`.
21    error_field_aliases: HashMap<String, String>,
22    /// Per-type PHP getter classification: maps an owner type's snake_case field
23    /// name to whether THAT field on THAT type requires `->getCamelCase()` syntax
24    /// (because the field's mapped PHP type is non-scalar and ext-php-rs emits a
25    /// `#[php(getter)]` method) rather than `->camelCase` property access.
26    /// Populated by `new_with_php_getters`; empty by default.
27    ///
28    /// Keying by (type, field) — not bare field name — is required because two
29    /// different types can declare the same field name with different scalarness
30    /// (e.g. `CrawlConfig.content: ContentConfig` is non-scalar while
31    /// `MarkdownResult.content: String` is scalar).
32    php_getter_map: PhpGetterMap,
33}
34
35/// Per-type PHP getter classification + chain-resolution metadata.
36///
37/// Holds enough information to resolve a multi-segment field path through the
38/// IR's nested type graph and pick the correct accessor style at each segment:
39///
40/// * `getters[type_name]` — set of field names on `type_name` whose PHP binding
41///   uses a `#[php(getter)]` method (caller must emit `->getCamelCase()`).
42/// * `field_types[type_name][field_name]` — the IR-resolved `Named` type that
43///   `field_name` traverses into, used to advance the "current type" cursor
44///   for the next path segment. Absent for terminal/scalar fields.
45/// * `root_type` — the IR type name backing the result variable at the start of
46///   any chain. When `None`, chain traversal degrades to per-segment lookup
47///   using a flattened union across all types (legacy bare-name behaviour),
48///   which produces false positives when field names collide across types.
49#[derive(Debug, Clone, Default)]
50pub struct PhpGetterMap {
51    pub getters: HashMap<String, HashSet<String>>,
52    pub field_types: HashMap<String, HashMap<String, String>>,
53    pub root_type: Option<String>,
54    /// All field names per type — used to detect when the recorded `root_type`
55    /// is a misclassification (a workspace-global root_type may not match the
56    /// actual return type of a per-fixture call). When `owner_type` is set but
57    /// `all_fields[owner_type]` doesn't contain `field_name`, the renderer
58    /// falls back to the bare-name union instead of trusting the (wrong) owner.
59    pub all_fields: HashMap<String, HashSet<String>>,
60}
61
62impl PhpGetterMap {
63    /// Returns true if `(owner_type, field_name)` requires getter-method syntax.
64    ///
65    /// When `owner_type` is `None` (root type unknown, or chain advanced into an
66    /// unmapped type), falls back to the union across all types: any type
67    /// declaring `field_name` as non-scalar marks it as needing a getter. This
68    /// is the legacy behaviour and is unsafe when field names collide.
69    pub fn needs_getter(&self, owner_type: Option<&str>, field_name: &str) -> bool {
70        if let Some(t) = owner_type {
71            // Only trust the owner-type classification if the type actually declares
72            // this field. A misclassified root_type (workspace-global guess that
73            // doesn't match the per-fixture call's actual return type) shouldn't
74            // shadow the bare-name fallback.
75            let owner_has_field = self.all_fields.get(t).is_some_and(|s| s.contains(field_name));
76            if owner_has_field {
77                if let Some(fields) = self.getters.get(t) {
78                    return fields.contains(field_name);
79                }
80            }
81        }
82        self.getters.values().any(|set| set.contains(field_name))
83    }
84
85    /// Returns the IR `Named` type that `field_name` traverses into for the
86    /// next chain segment, or `None` if the field is terminal/scalar/unknown.
87    pub fn advance(&self, owner_type: Option<&str>, field_name: &str) -> Option<String> {
88        let owner = owner_type?;
89        self.field_types.get(owner).and_then(|m| m.get(field_name).cloned())
90    }
91
92    /// True when no per-type information is recorded — equivalent to the legacy
93    /// "no PHP getter resolution" code path.
94    pub fn is_empty(&self) -> bool {
95        self.getters.is_empty()
96    }
97}
98
99/// A parsed segment of a field path.
100#[derive(Debug, Clone)]
101enum PathSegment {
102    /// Struct field access: `foo`
103    Field(String),
104    /// Array field access with explicit numeric index: `foo[N]`
105    ///
106    /// The `index` is the integer parsed from the bracket (e.g. `choices[2]` → index 2).
107    /// When synthesised by `inject_array_indexing` the index defaults to `0`.
108    ArrayField { name: String, index: usize },
109    /// Map/dict key access: `foo[key]`
110    MapAccess { field: String, key: String },
111    /// Length/count of the preceding collection: `.length`
112    Length,
113}
114
115impl FieldResolver {
116    /// Create a new resolver from the e2e config's `fields` aliases,
117    /// `fields_optional` set, `result_fields` set, `fields_array` set,
118    /// and `fields_method_calls` set.
119    pub fn new(
120        fields: &HashMap<String, String>,
121        optional: &HashSet<String>,
122        result_fields: &HashSet<String>,
123        array_fields: &HashSet<String>,
124        method_calls: &HashSet<String>,
125    ) -> Self {
126        Self {
127            aliases: fields.clone(),
128            optional_fields: optional.clone(),
129            result_fields: result_fields.clone(),
130            array_fields: array_fields.clone(),
131            method_calls: method_calls.clone(),
132            error_field_aliases: HashMap::new(),
133            php_getter_map: PhpGetterMap::default(),
134        }
135    }
136
137    /// Create a new resolver that also includes error-path field aliases.
138    ///
139    /// `error_field_aliases` maps fixture sub-field names (the part after `"error."`)
140    /// to the actual field names on the error type, enabling `accessor_for_error` to
141    /// resolve fields like `"status_code"` against the error value.
142    pub fn new_with_error_aliases(
143        fields: &HashMap<String, String>,
144        optional: &HashSet<String>,
145        result_fields: &HashSet<String>,
146        array_fields: &HashSet<String>,
147        method_calls: &HashSet<String>,
148        error_field_aliases: &HashMap<String, String>,
149    ) -> Self {
150        Self {
151            aliases: fields.clone(),
152            optional_fields: optional.clone(),
153            result_fields: result_fields.clone(),
154            array_fields: array_fields.clone(),
155            method_calls: method_calls.clone(),
156            error_field_aliases: error_field_aliases.clone(),
157            php_getter_map: PhpGetterMap::default(),
158        }
159    }
160
161    /// Create a new resolver that also knows which PHP fields need getter-method syntax.
162    ///
163    /// `php_getter_map` carries a per-`(type_name, field_name)` classification: the PHP
164    /// accessor renderer emits `->getCamelCase()` when `(owner_type, field)` is
165    /// recorded as needing a getter, and `->camelCase` property syntax otherwise.
166    /// This matches the ext-php-rs 0.15.x behaviour where `#[php(getter)]` is used for
167    /// non-scalar fields (Named structs, Vec<Named>, Map, etc.) while `#[php(prop)]` is
168    /// used for scalar-compatible fields.
169    ///
170    /// Keying by (type, field) — not bare field name — is essential because the same
171    /// field name can have different scalarness on different types. The map also carries
172    /// per-type field→nested-type mappings so the renderer can walk a path like
173    /// `outer.inner.content` through the IR, advancing the current-type cursor at each
174    /// segment.
175    pub fn new_with_php_getters(
176        fields: &HashMap<String, String>,
177        optional: &HashSet<String>,
178        result_fields: &HashSet<String>,
179        array_fields: &HashSet<String>,
180        method_calls: &HashSet<String>,
181        error_field_aliases: &HashMap<String, String>,
182        php_getter_map: PhpGetterMap,
183    ) -> Self {
184        Self {
185            aliases: fields.clone(),
186            optional_fields: optional.clone(),
187            result_fields: result_fields.clone(),
188            array_fields: array_fields.clone(),
189            method_calls: method_calls.clone(),
190            error_field_aliases: error_field_aliases.clone(),
191            php_getter_map,
192        }
193    }
194
195    /// Resolve a fixture field path to the actual struct path.
196    /// Falls back to the field itself if no alias exists.
197    pub fn resolve<'a>(&'a self, fixture_field: &'a str) -> &'a str {
198        self.aliases
199            .get(fixture_field)
200            .map(String::as_str)
201            .unwrap_or(fixture_field)
202    }
203
204    /// Check if a resolved field path is optional.
205    pub fn is_optional(&self, field: &str) -> bool {
206        if self.optional_fields.contains(field) {
207            return true;
208        }
209        let index_normalized = normalize_numeric_indices(field);
210        if index_normalized != field && self.optional_fields.contains(index_normalized.as_str()) {
211            return true;
212        }
213        // Also check with all numeric indices stripped: "choices[0].message.tool_calls"
214        // should match optional_fields entry "choices.message.tool_calls".
215        let de_indexed = strip_numeric_indices(field);
216        if de_indexed != field && self.optional_fields.contains(de_indexed.as_str()) {
217            return true;
218        }
219        let normalized = field.replace("[].", ".");
220        if normalized != field && self.optional_fields.contains(normalized.as_str()) {
221            return true;
222        }
223        for af in &self.array_fields {
224            if let Some(rest) = field.strip_prefix(af.as_str()) {
225                if let Some(rest) = rest.strip_prefix('.') {
226                    let with_bracket = format!("{af}[].{rest}");
227                    if self.optional_fields.contains(with_bracket.as_str()) {
228                        return true;
229                    }
230                }
231            }
232        }
233        false
234    }
235
236    /// Check if a fixture field has an explicit alias mapping.
237    pub fn has_alias(&self, fixture_field: &str) -> bool {
238        self.aliases.contains_key(fixture_field)
239    }
240
241    /// Check whether `field_name` is configured as an explicit result field.
242    ///
243    /// Returns true only when the caller has populated `result_fields` AND the
244    /// field name is present. Empty `result_fields` always returns false — use
245    /// `is_valid_for_result` for the default-allow semantics.
246    pub fn has_explicit_field(&self, field_name: &str) -> bool {
247        if self.result_fields.is_empty() {
248            return false;
249        }
250        self.result_fields.contains(field_name)
251    }
252
253    /// Check whether a fixture field path is valid for the configured result type.
254    pub fn is_valid_for_result(&self, fixture_field: &str) -> bool {
255        if self.result_fields.is_empty() {
256            return true;
257        }
258        let resolved = self.resolve(fixture_field);
259        let first_segment = resolved.split('.').next().unwrap_or(resolved);
260        let first_segment = first_segment.split('[').next().unwrap_or(first_segment);
261        self.result_fields.contains(first_segment)
262    }
263
264    /// Check if a resolved field is an array/Vec type.
265    pub fn is_array(&self, field: &str) -> bool {
266        self.array_fields.contains(field)
267    }
268
269    /// Check if a field name is the root of a collection type (i.e., the field
270    /// itself returns a `Vec`/array, even though it is not in `fields_array`
271    /// directly).
272    ///
273    /// `fields_array` tracks traversal paths like `choices[0].message.tool_calls`
274    /// — the array element paths — not the bare collection accessor (`choices`).
275    /// `fields_optional` may also contain paths like `data[0].url` that reveal
276    /// `data` is a collection root.
277    ///
278    /// Returns `true` when any entry in `array_fields` or `optional_fields`
279    /// starts with `{field}[`, indicating that `field` is the top-level
280    /// collection getter.
281    pub fn is_collection_root(&self, field: &str) -> bool {
282        let prefix = format!("{field}[");
283        self.array_fields.iter().any(|af| af.starts_with(&prefix))
284            || self.optional_fields.iter().any(|of| of.starts_with(&prefix))
285    }
286
287    /// Check if a resolved field path traverses a tagged-union variant.
288    ///
289    /// Returns `Some((prefix, variant, suffix))` where:
290    /// - `prefix` is the path up to (but not including) the tagged-union field
291    ///   (e.g., `"metadata.format"`)
292    /// - `variant` is the tagged-union accessor segment
293    ///   (e.g., `"excel"`)
294    /// - `suffix` is the remaining path after the variant
295    ///   (e.g., `"sheet_count"`)
296    ///
297    /// Returns `None` if no tagged-union segment exists in the path.
298    pub fn tagged_union_split(&self, fixture_field: &str) -> Option<(String, String, String)> {
299        let resolved = self.resolve(fixture_field);
300        let segments: Vec<&str> = resolved.split('.').collect();
301        let mut path_so_far = String::new();
302        for (i, seg) in segments.iter().enumerate() {
303            if !path_so_far.is_empty() {
304                path_so_far.push('.');
305            }
306            path_so_far.push_str(seg);
307            if self.method_calls.contains(&path_so_far) {
308                // Everything before the last segment of path_so_far is the prefix.
309                let prefix = segments[..i].join(".");
310                let variant = (*seg).to_string();
311                let suffix = segments[i + 1..].join(".");
312                return Some((prefix, variant, suffix));
313            }
314        }
315        None
316    }
317
318    /// Check if a resolved field path contains a non-numeric map access.
319    pub fn has_map_access(&self, fixture_field: &str) -> bool {
320        let resolved = self.resolve(fixture_field);
321        let segments = parse_path(resolved);
322        segments.iter().any(|s| {
323            if let PathSegment::MapAccess { key, .. } = s {
324                !key.chars().all(|c| c.is_ascii_digit())
325            } else {
326                false
327            }
328        })
329    }
330
331    /// Generate a language-specific accessor expression.
332    pub fn accessor(&self, fixture_field: &str, language: &str, result_var: &str) -> String {
333        let resolved = self.resolve(fixture_field);
334        let segments = parse_path(resolved);
335        let segments = self.inject_array_indexing(segments);
336        match language {
337            "java" => render_java_with_optionals(&segments, result_var, &self.optional_fields),
338            "kotlin" => render_kotlin_with_optionals(&segments, result_var, &self.optional_fields),
339            // kotlin_android data classes expose fields as Kotlin properties (no parens),
340            // not as Java-style getter methods. Use the dedicated renderer.
341            "kotlin_android" => render_kotlin_android_with_optionals(&segments, result_var, &self.optional_fields),
342            "rust" => render_rust_with_optionals(&segments, result_var, &self.optional_fields, &self.method_calls),
343            "csharp" => render_csharp_with_optionals(&segments, result_var, &self.optional_fields),
344            "zig" => render_zig_with_optionals(&segments, result_var, &self.optional_fields, &self.method_calls),
345            "swift" => render_swift_with_optionals(&segments, result_var, &self.optional_fields),
346            "dart" => render_dart_with_optionals(&segments, result_var, &self.optional_fields),
347            "php" if !self.php_getter_map.is_empty() => {
348                render_php_with_getters(&segments, result_var, &self.php_getter_map)
349            }
350            _ => render_accessor(&segments, language, result_var),
351        }
352    }
353
354    /// Generate a language-specific accessor expression for an error-path field.
355    ///
356    /// Used when `assertion_type == "error"` and the fixture declares a `field`
357    /// like `"error.status_code"`. The caller strips the `"error."` prefix and
358    /// passes the sub-field name (e.g. `"status_code"`) here.
359    ///
360    /// Resolves against `error_field_aliases` (instead of the success-path
361    /// `aliases`). Falls back to direct field access (i.e. `err_var.status_code`)
362    /// when no alias exists.
363    ///
364    /// For Rust, uses `render_rust_with_optionals` so that fields in
365    /// `method_calls` emit parentheses (e.g. `err.status_code()` when
366    /// `"status_code"` is in `fields_method_calls`).
367    pub fn accessor_for_error(&self, sub_field: &str, language: &str, err_var: &str) -> String {
368        let resolved = self
369            .error_field_aliases
370            .get(sub_field)
371            .map(String::as_str)
372            .unwrap_or(sub_field);
373        let segments = parse_path(resolved);
374        // Error fields are simple scalar fields — no array injection needed.
375        // For Rust, delegate to render_rust_with_optionals so method_calls are honoured.
376        match language {
377            "rust" => render_rust_with_optionals(&segments, err_var, &self.optional_fields, &self.method_calls),
378            _ => render_accessor(&segments, language, err_var),
379        }
380    }
381
382    /// Check whether a sub-field (the part after `"error."`) has an entry in
383    /// `error_field_aliases` or if there are any error aliases at all.
384    ///
385    /// When there are no error aliases configured, callers fall back to
386    /// direct field access, which is the safe default for known public fields
387    /// like `status_code` on `LiterLlmError`.
388    pub fn has_error_aliases(&self) -> bool {
389        !self.error_field_aliases.is_empty()
390    }
391
392    fn inject_array_indexing(&self, segments: Vec<PathSegment>) -> Vec<PathSegment> {
393        if self.array_fields.is_empty() {
394            return segments;
395        }
396        let len = segments.len();
397        let mut result = Vec::with_capacity(len);
398        let mut path_so_far = String::new();
399        for i in 0..len {
400            let seg = &segments[i];
401            match seg {
402                PathSegment::Field(f) => {
403                    if !path_so_far.is_empty() {
404                        path_so_far.push('.');
405                    }
406                    path_so_far.push_str(f);
407                    let next_is_length = i + 1 < len && matches!(segments[i + 1], PathSegment::Length);
408                    if i + 1 < len && self.array_fields.contains(&path_so_far) && !next_is_length {
409                        // Config-registered array field without explicit index — default to 0.
410                        result.push(PathSegment::ArrayField {
411                            name: f.clone(),
412                            index: 0,
413                        });
414                    } else {
415                        result.push(seg.clone());
416                    }
417                }
418                // Explicit ArrayField from parse_path — pass through unchanged; the user's
419                // explicit index takes precedence over any config default.
420                PathSegment::ArrayField { .. } => {
421                    result.push(seg.clone());
422                }
423                PathSegment::MapAccess { field, key } => {
424                    if !path_so_far.is_empty() {
425                        path_so_far.push('.');
426                    }
427                    path_so_far.push_str(field);
428                    let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
429                    if is_numeric && self.array_fields.contains(&path_so_far) {
430                        // Numeric map-access on a registered array field — upgrade to ArrayField.
431                        let index: usize = key.parse().unwrap_or(0);
432                        result.push(PathSegment::ArrayField {
433                            name: field.clone(),
434                            index,
435                        });
436                    } else {
437                        result.push(seg.clone());
438                    }
439                }
440                _ => {
441                    result.push(seg.clone());
442                }
443            }
444        }
445        result
446    }
447
448    /// Generate a Rust variable binding that unwraps an Optional string field.
449    pub fn rust_unwrap_binding(&self, fixture_field: &str, result_var: &str) -> Option<(String, String)> {
450        let resolved = self.resolve(fixture_field);
451        if !self.is_optional(resolved) {
452            return None;
453        }
454        let segments = parse_path(resolved);
455        let segments = self.inject_array_indexing(segments);
456        // Sanitize the resolved path into a snake_case Rust identifier:
457        // 1. `.` and `[` become `_` separators, `]` is dropped.
458        // 2. Collapse runs of `_` so `foo[].bar` → `foo__bar` → `foo_bar`
459        //    and strip any leading/trailing underscores.
460        let local_var = {
461            let raw = resolved.replace(['.', '['], "_").replace(']', "");
462            let mut collapsed = String::with_capacity(raw.len());
463            let mut prev_underscore = false;
464            for ch in raw.chars() {
465                if ch == '_' {
466                    if !prev_underscore {
467                        collapsed.push('_');
468                    }
469                    prev_underscore = true;
470                } else {
471                    collapsed.push(ch);
472                    prev_underscore = false;
473                }
474            }
475            collapsed.trim_matches('_').to_string()
476        };
477        let accessor = render_accessor(&segments, "rust", result_var);
478        let has_map_access = segments.iter().any(|s| {
479            if let PathSegment::MapAccess { key, .. } = s {
480                !key.chars().all(|c| c.is_ascii_digit())
481            } else {
482                false
483            }
484        });
485        let is_array = self.is_array(resolved);
486        let binding = if has_map_access {
487            format!("let {local_var} = {accessor}.unwrap_or(\"\");")
488        } else if is_array {
489            format!("let {local_var} = {accessor}.as_deref().unwrap_or(&[]);")
490        } else {
491            format!("let {local_var} = {accessor}.as_ref().map(|v| v.to_string()).unwrap_or_default();")
492        };
493        Some((binding, local_var))
494    }
495}
496
497/// Strip all numeric indices from a path so `"choices[0].message.tool_calls"` →
498/// `"choices.message.tool_calls"`. Used by `is_optional` to match entries like
499/// `"choices.message.tool_calls"` in `optional_fields` when the caller supplies a
500/// path that includes a concrete index.
501fn strip_numeric_indices(path: &str) -> String {
502    let mut result = String::with_capacity(path.len());
503    let mut chars = path.chars().peekable();
504    while let Some(c) = chars.next() {
505        if c == '[' {
506            let mut key = String::new();
507            let mut closed = false;
508            for inner in chars.by_ref() {
509                if inner == ']' {
510                    closed = true;
511                    break;
512                }
513                key.push(inner);
514            }
515            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
516                // Numeric index — drop it entirely (including any trailing dot).
517            } else {
518                result.push('[');
519                result.push_str(&key);
520                if closed {
521                    result.push(']');
522                }
523            }
524        } else {
525            result.push(c);
526        }
527    }
528    // Collapse any double-dots introduced by dropping `[N].` sequences.
529    while result.contains("..") {
530        result = result.replace("..", ".");
531    }
532    if result.starts_with('.') {
533        result.remove(0);
534    }
535    result
536}
537
538fn normalize_numeric_indices(path: &str) -> String {
539    let mut result = String::with_capacity(path.len());
540    let mut chars = path.chars().peekable();
541    while let Some(c) = chars.next() {
542        if c == '[' {
543            let mut key = String::new();
544            let mut closed = false;
545            for inner in chars.by_ref() {
546                if inner == ']' {
547                    closed = true;
548                    break;
549                }
550                key.push(inner);
551            }
552            if closed && !key.is_empty() && key.chars().all(|k| k.is_ascii_digit()) {
553                result.push_str("[0]");
554            } else {
555                result.push('[');
556                result.push_str(&key);
557                if closed {
558                    result.push(']');
559                }
560            }
561        } else {
562            result.push(c);
563        }
564    }
565    result
566}
567
568fn parse_path(path: &str) -> Vec<PathSegment> {
569    let mut segments = Vec::new();
570    for part in path.split('.') {
571        if part == "length" || part == "count" || part == "size" {
572            segments.push(PathSegment::Length);
573        } else if let Some(bracket_pos) = part.find('[') {
574            let name = part[..bracket_pos].to_string();
575            let key = part[bracket_pos + 1..].trim_end_matches(']').to_string();
576            if key.is_empty() {
577                // `foo[]` — bare array bracket, index defaults to 0 (upgraded by inject_array_indexing).
578                segments.push(PathSegment::ArrayField { name, index: 0 });
579            } else if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
580                // `foo[N]` — user-typed explicit numeric index.
581                let index: usize = key.parse().unwrap_or(0);
582                segments.push(PathSegment::ArrayField { name, index });
583            } else {
584                // `foo[key]` — string-keyed map access.
585                segments.push(PathSegment::MapAccess { field: name, key });
586            }
587        } else {
588            segments.push(PathSegment::Field(part.to_string()));
589        }
590    }
591    segments
592}
593
594fn render_accessor(segments: &[PathSegment], language: &str, result_var: &str) -> String {
595    match language {
596        "rust" => render_rust(segments, result_var),
597        "python" => render_dot_access(segments, result_var, "python"),
598        "typescript" | "node" => render_typescript(segments, result_var),
599        "wasm" => render_wasm(segments, result_var),
600        "go" => render_go(segments, result_var),
601        "java" => render_java(segments, result_var),
602        "kotlin" => render_kotlin(segments, result_var),
603        "kotlin_android" => render_kotlin_android(segments, result_var),
604        "csharp" => render_pascal_dot(segments, result_var),
605        "ruby" => render_dot_access(segments, result_var, "ruby"),
606        "php" => render_php(segments, result_var),
607        "elixir" => render_dot_access(segments, result_var, "elixir"),
608        "r" => render_r(segments, result_var),
609        "c" => render_c(segments, result_var),
610        "swift" => render_swift(segments, result_var),
611        "dart" => render_dart(segments, result_var),
612        _ => render_dot_access(segments, result_var, language),
613    }
614}
615
616/// Generate a Swift accessor expression.
617///
618/// Alef now emits first-class Swift structs (`public struct Foo: Codable { public let
619/// id: String }`) for most DTO types, where fields are properties — property access
620/// uses `.id` (no parens). The remaining typealias-to-opaque types (e.g. request
621/// types with Vec/Map/Named fields that aren't first-class candidates) are accessed
622/// via the swift-bridge-generated method-call syntax `.id()`, but in e2e tests these
623/// typealias types are method inputs / streaming outputs rather than parents for
624/// field-access chains, so property syntax works in practice. If a future e2e test
625/// asserts on a field-access chain rooted in an opaque type, a per-type
626/// `SwiftFirstClassMap` (analogous to `PhpGetterMap`) would be needed.
627fn render_swift(segments: &[PathSegment], result_var: &str) -> String {
628    let mut out = result_var.to_string();
629    for seg in segments {
630        match seg {
631            PathSegment::Field(f) => {
632                out.push('.');
633                out.push_str(f);
634            }
635            PathSegment::ArrayField { name, index } => {
636                out.push('.');
637                out.push_str(name);
638                out.push_str(&format!("[{index}]"));
639            }
640            PathSegment::MapAccess { field, key } => {
641                out.push('.');
642                out.push_str(field);
643                if key.chars().all(|c| c.is_ascii_digit()) {
644                    out.push_str(&format!("[{key}]"));
645                } else {
646                    out.push_str(&format!("[\"{key}\"]"));
647                }
648            }
649            PathSegment::Length => {
650                out.push_str(".count");
651            }
652        }
653    }
654    out
655}
656
657/// Generate a Swift accessor expression with optional chaining.
658///
659/// When an intermediate field is in `optional_fields`, a `?` is inserted after the
660/// `()` call on that segment so the next access uses `?.`. This prevents compile
661/// errors when accessing members through an `Optional<T>` in Swift.
662///
663/// Example: for `metadata.format.excel.sheet_count` where `metadata.format` and
664/// `metadata.format.excel` are optional, the result is:
665/// `result.metadata().format()?.excel()?.sheet_count()`
666fn render_swift_with_optionals(
667    segments: &[PathSegment],
668    result_var: &str,
669    optional_fields: &HashSet<String>,
670) -> String {
671    let mut out = result_var.to_string();
672    let mut path_so_far = String::new();
673    let total = segments.len();
674    for (i, seg) in segments.iter().enumerate() {
675        let is_leaf = i == total - 1;
676        match seg {
677            PathSegment::Field(f) => {
678                if !path_so_far.is_empty() {
679                    path_so_far.push('.');
680                }
681                path_so_far.push_str(f);
682                out.push('.');
683                out.push_str(f);
684                // First-class Swift struct fields are properties (no parens).
685                // Insert `?` after the property name for non-leaf optional fields so the
686                // next member access becomes `?.`.
687                if !is_leaf && optional_fields.contains(&path_so_far) {
688                    out.push('?');
689                }
690            }
691            PathSegment::ArrayField { name, index } => {
692                if !path_so_far.is_empty() {
693                    path_so_far.push('.');
694                }
695                path_so_far.push_str(name);
696                let is_optional = optional_fields.contains(&path_so_far);
697                out.push('.');
698                out.push_str(name);
699                if is_optional {
700                    // Optional<[T]>: unwrap before indexing.
701                    out.push_str(&format!("?[{index}]"));
702                } else {
703                    out.push_str(&format!("[{index}]"));
704                }
705                path_so_far.push_str("[0]");
706                let _ = is_leaf;
707            }
708            PathSegment::MapAccess { field, key } => {
709                if !path_so_far.is_empty() {
710                    path_so_far.push('.');
711                }
712                path_so_far.push_str(field);
713                out.push('.');
714                out.push_str(field);
715                if key.chars().all(|c| c.is_ascii_digit()) {
716                    out.push_str(&format!("[{key}]"));
717                } else {
718                    out.push_str(&format!("[\"{key}\"]"));
719                }
720            }
721            PathSegment::Length => {
722                out.push_str(".count");
723            }
724        }
725    }
726    out
727}
728
729fn render_rust(segments: &[PathSegment], result_var: &str) -> String {
730    let mut out = result_var.to_string();
731    for seg in segments {
732        match seg {
733            PathSegment::Field(f) => {
734                out.push('.');
735                out.push_str(&f.to_snake_case());
736            }
737            PathSegment::ArrayField { name, index } => {
738                out.push('.');
739                out.push_str(&name.to_snake_case());
740                out.push_str(&format!("[{index}]"));
741            }
742            PathSegment::MapAccess { field, key } => {
743                out.push('.');
744                out.push_str(&field.to_snake_case());
745                if key.chars().all(|c| c.is_ascii_digit()) {
746                    out.push_str(&format!("[{key}]"));
747                } else {
748                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
749                }
750            }
751            PathSegment::Length => {
752                out.push_str(".len()");
753            }
754        }
755    }
756    out
757}
758
759fn render_dot_access(segments: &[PathSegment], result_var: &str, language: &str) -> String {
760    let mut out = result_var.to_string();
761    for seg in segments {
762        match seg {
763            PathSegment::Field(f) => {
764                out.push('.');
765                out.push_str(f);
766            }
767            PathSegment::ArrayField { name, index } => {
768                if language == "elixir" {
769                    let current = std::mem::take(&mut out);
770                    out = format!("Enum.at({current}.{name}, {index})");
771                } else {
772                    out.push('.');
773                    out.push_str(name);
774                    out.push_str(&format!("[{index}]"));
775                }
776            }
777            PathSegment::MapAccess { field, key } => {
778                let is_numeric = key.chars().all(|c| c.is_ascii_digit());
779                if is_numeric && language == "elixir" {
780                    let current = std::mem::take(&mut out);
781                    out = format!("Enum.at({current}.{field}, {key})");
782                } else {
783                    out.push('.');
784                    out.push_str(field);
785                    if is_numeric {
786                        let idx: usize = key.parse().unwrap_or(0);
787                        out.push_str(&format!("[{idx}]"));
788                    } else if language == "elixir" || language == "ruby" {
789                        // Ruby/Elixir hashes use `["key"]` bracket access (Ruby's Hash has
790                        // no `get` method; Elixir maps use bracket access too).
791                        out.push_str(&format!("[\"{key}\"]"));
792                    } else {
793                        out.push_str(&format!(".get(\"{key}\")"));
794                    }
795                }
796            }
797            PathSegment::Length => match language {
798                "ruby" => out.push_str(".length"),
799                "elixir" => {
800                    let current = std::mem::take(&mut out);
801                    out = format!("length({current})");
802                }
803                "gleam" => {
804                    let current = std::mem::take(&mut out);
805                    out = format!("list.length({current})");
806                }
807                _ => {
808                    let current = std::mem::take(&mut out);
809                    out = format!("len({current})");
810                }
811            },
812        }
813    }
814    out
815}
816
817fn render_typescript(segments: &[PathSegment], result_var: &str) -> String {
818    let mut out = result_var.to_string();
819    for seg in segments {
820        match seg {
821            PathSegment::Field(f) => {
822                out.push('.');
823                out.push_str(&f.to_lower_camel_case());
824            }
825            PathSegment::ArrayField { name, index } => {
826                out.push('.');
827                out.push_str(&name.to_lower_camel_case());
828                out.push_str(&format!("[{index}]"));
829            }
830            PathSegment::MapAccess { field, key } => {
831                out.push('.');
832                out.push_str(&field.to_lower_camel_case());
833                // Numeric (digit-only) keys index into arrays as integers, not as
834                // string-keyed object properties; emit `[0]` not `["0"]`.
835                if !key.is_empty() && key.chars().all(|c| c.is_ascii_digit()) {
836                    out.push_str(&format!("[{key}]"));
837                } else {
838                    out.push_str(&format!("[\"{key}\"]"));
839                }
840            }
841            PathSegment::Length => {
842                out.push_str(".length");
843            }
844        }
845    }
846    out
847}
848
849fn render_wasm(segments: &[PathSegment], result_var: &str) -> String {
850    let mut out = result_var.to_string();
851    for seg in segments {
852        match seg {
853            PathSegment::Field(f) => {
854                out.push('.');
855                out.push_str(&f.to_lower_camel_case());
856            }
857            PathSegment::ArrayField { name, index } => {
858                out.push('.');
859                out.push_str(&name.to_lower_camel_case());
860                out.push_str(&format!("[{index}]"));
861            }
862            PathSegment::MapAccess { field, key } => {
863                out.push('.');
864                out.push_str(&field.to_lower_camel_case());
865                out.push_str(&format!(".get(\"{key}\")"));
866            }
867            PathSegment::Length => {
868                out.push_str(".length");
869            }
870        }
871    }
872    out
873}
874
875fn render_go(segments: &[PathSegment], result_var: &str) -> String {
876    let mut out = result_var.to_string();
877    for seg in segments {
878        match seg {
879            PathSegment::Field(f) => {
880                out.push('.');
881                out.push_str(&to_go_name(f));
882            }
883            PathSegment::ArrayField { name, index } => {
884                out.push('.');
885                out.push_str(&to_go_name(name));
886                out.push_str(&format!("[{index}]"));
887            }
888            PathSegment::MapAccess { field, key } => {
889                out.push('.');
890                out.push_str(&to_go_name(field));
891                if key.chars().all(|c| c.is_ascii_digit()) {
892                    out.push_str(&format!("[{key}]"));
893                } else {
894                    out.push_str(&format!("[\"{key}\"]"));
895                }
896            }
897            PathSegment::Length => {
898                let current = std::mem::take(&mut out);
899                out = format!("len({current})");
900            }
901        }
902    }
903    out
904}
905
906fn render_java(segments: &[PathSegment], result_var: &str) -> String {
907    let mut out = result_var.to_string();
908    for seg in segments {
909        match seg {
910            PathSegment::Field(f) => {
911                out.push('.');
912                out.push_str(&f.to_lower_camel_case());
913                out.push_str("()");
914            }
915            PathSegment::ArrayField { name, index } => {
916                out.push('.');
917                out.push_str(&name.to_lower_camel_case());
918                out.push_str(&format!("().get({index})"));
919            }
920            PathSegment::MapAccess { field, key } => {
921                out.push('.');
922                out.push_str(&field.to_lower_camel_case());
923                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
924                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
925                if is_numeric {
926                    out.push_str(&format!("().get({key})"));
927                } else {
928                    out.push_str(&format!("().get(\"{key}\")"));
929                }
930            }
931            PathSegment::Length => {
932                out.push_str(".size()");
933            }
934        }
935    }
936    out
937}
938
939/// Wrap a Kotlin getter name in backticks when it collides with a Kotlin hard keyword.
940///
941/// Hard keywords cannot be used as identifiers without escaping, so `result.object()`
942/// is a syntax error; `` result.`object`() `` is the legal form.
943fn kotlin_getter(name: &str) -> String {
944    let camel = name.to_lower_camel_case();
945    match camel.as_str() {
946        "as" | "break" | "class" | "continue" | "do" | "else" | "false" | "for" | "fun" | "if" | "in" | "interface"
947        | "is" | "null" | "object" | "package" | "return" | "super" | "this" | "throw" | "true" | "try"
948        | "typealias" | "typeof" | "val" | "var" | "when" | "while" => format!("`{camel}`"),
949        _ => camel,
950    }
951}
952
953fn render_kotlin(segments: &[PathSegment], result_var: &str) -> String {
954    let mut out = result_var.to_string();
955    for seg in segments {
956        match seg {
957            PathSegment::Field(f) => {
958                out.push('.');
959                out.push_str(&kotlin_getter(f));
960                out.push_str("()");
961            }
962            PathSegment::ArrayField { name, index } => {
963                out.push('.');
964                out.push_str(&kotlin_getter(name));
965                if *index == 0 {
966                    out.push_str("().first()");
967                } else {
968                    out.push_str(&format!("().get({index})"));
969                }
970            }
971            PathSegment::MapAccess { field, key } => {
972                out.push('.');
973                out.push_str(&kotlin_getter(field));
974                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
975                if is_numeric {
976                    out.push_str(&format!("().get({key})"));
977                } else {
978                    out.push_str(&format!("().get(\"{key}\")"));
979                }
980            }
981            PathSegment::Length => {
982                out.push_str(".size");
983            }
984        }
985    }
986    out
987}
988
989fn render_java_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
990    let mut out = result_var.to_string();
991    let mut path_so_far = String::new();
992    for (i, seg) in segments.iter().enumerate() {
993        let is_leaf = i == segments.len() - 1;
994        match seg {
995            PathSegment::Field(f) => {
996                if !path_so_far.is_empty() {
997                    path_so_far.push('.');
998                }
999                path_so_far.push_str(f);
1000                out.push('.');
1001                out.push_str(&f.to_lower_camel_case());
1002                out.push_str("()");
1003                let _ = is_leaf;
1004                let _ = optional_fields;
1005            }
1006            PathSegment::ArrayField { name, index } => {
1007                if !path_so_far.is_empty() {
1008                    path_so_far.push('.');
1009                }
1010                path_so_far.push_str(name);
1011                out.push('.');
1012                out.push_str(&name.to_lower_camel_case());
1013                out.push_str(&format!("().get({index})"));
1014            }
1015            PathSegment::MapAccess { field, key } => {
1016                if !path_so_far.is_empty() {
1017                    path_so_far.push('.');
1018                }
1019                path_so_far.push_str(field);
1020                out.push('.');
1021                out.push_str(&field.to_lower_camel_case());
1022                // Numeric keys index into List<T> (.get(int)); string keys index into Map<String, V>.
1023                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1024                if is_numeric {
1025                    out.push_str(&format!("().get({key})"));
1026                } else {
1027                    out.push_str(&format!("().get(\"{key}\")"));
1028                }
1029            }
1030            PathSegment::Length => {
1031                out.push_str(".size()");
1032            }
1033        }
1034    }
1035    out
1036}
1037
1038/// Kotlin variant of `render_java_with_optionals` using Kotlin idioms.
1039///
1040/// When the previous field in the chain is optional (nullable), uses `?.`
1041/// safe-call navigation for the next segment so the Kotlin compiler is
1042/// satisfied by the nullable receiver.
1043///
1044/// Nullability is **sticky**: once a `?.` safe-call has been emitted for any
1045/// segment, all subsequent segments also use `?.` because they operate on a
1046/// nullable receiver. A non-optional field after a `?.` call still returns
1047/// `T?` (because the whole chain can be null if any prefix was null).
1048///
1049/// Example: for `toolCalls[0].function.name` where `toolCalls` is optional:
1050/// `result.toolCalls()?.first()?.function()?.name()` — even though `function`
1051/// and `name` are themselves non-optional, they follow a `?.` chain.
1052fn render_kotlin_with_optionals(
1053    segments: &[PathSegment],
1054    result_var: &str,
1055    optional_fields: &HashSet<String>,
1056) -> String {
1057    let mut out = result_var.to_string();
1058    let mut path_so_far = String::new();
1059    // Track whether the previous segment returned a nullable type. Starts
1060    // false because `result_var` is always non-null.
1061    //
1062    // This flag is sticky: once set to true it stays true for the rest of
1063    // the chain because a `?.` call returns `T?` regardless of whether the
1064    // subsequent field itself is declared optional. All accesses on a
1065    // nullable receiver must also use `?.`.
1066    let mut prev_was_nullable = false;
1067    for seg in segments {
1068        let nav = if prev_was_nullable { "?." } else { "." };
1069        match seg {
1070            PathSegment::Field(f) => {
1071                if !path_so_far.is_empty() {
1072                    path_so_far.push('.');
1073                }
1074                path_so_far.push_str(f);
1075                // After this call, the receiver is nullable if the field is in
1076                // optional_fields (the Java @Nullable annotation makes the
1077                // return type T? in Kotlin) OR if the incoming receiver was
1078                // already nullable (sticky: `?.` call yields `T?`).
1079                let is_optional = optional_fields.contains(&path_so_far);
1080                out.push_str(nav);
1081                out.push_str(&kotlin_getter(f));
1082                out.push_str("()");
1083                prev_was_nullable = prev_was_nullable || is_optional;
1084            }
1085            PathSegment::ArrayField { name, index } => {
1086                if !path_so_far.is_empty() {
1087                    path_so_far.push('.');
1088                }
1089                path_so_far.push_str(name);
1090                let is_optional = optional_fields.contains(&path_so_far);
1091                out.push_str(nav);
1092                out.push_str(&kotlin_getter(name));
1093                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1094                if *index == 0 {
1095                    out.push_str(&format!("(){safe}.first()"));
1096                } else {
1097                    out.push_str(&format!("(){safe}.get({index})"));
1098                }
1099                // Record the "[0]" suffix so subsequent optional-field checks against
1100                // paths like "choices[0].message.tool_calls" continue to match when the
1101                // optional_fields set uses indexed keys (mirrors the Rust renderer).
1102                path_so_far.push_str("[0]");
1103                prev_was_nullable = prev_was_nullable || is_optional;
1104            }
1105            PathSegment::MapAccess { field, key } => {
1106                if !path_so_far.is_empty() {
1107                    path_so_far.push('.');
1108                }
1109                path_so_far.push_str(field);
1110                let is_optional = optional_fields.contains(&path_so_far);
1111                out.push_str(nav);
1112                out.push_str(&kotlin_getter(field));
1113                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1114                if is_numeric {
1115                    if prev_was_nullable || is_optional {
1116                        out.push_str(&format!("()?.get({key})"));
1117                    } else {
1118                        out.push_str(&format!("().get({key})"));
1119                    }
1120                } else if prev_was_nullable || is_optional {
1121                    out.push_str(&format!("()?.get(\"{key}\")"));
1122                } else {
1123                    out.push_str(&format!("().get(\"{key}\")"));
1124                }
1125                prev_was_nullable = prev_was_nullable || is_optional;
1126            }
1127            PathSegment::Length => {
1128                // .size is a Kotlin property, no () needed.
1129                // If the previous field was nullable, use ?.size
1130                let size_nav = if prev_was_nullable { "?" } else { "" };
1131                out.push_str(&format!("{size_nav}.size"));
1132                prev_was_nullable = false;
1133            }
1134        }
1135    }
1136    out
1137}
1138
1139/// kotlin_android variant of `render_kotlin_with_optionals`.
1140///
1141/// kotlin_android generates Kotlin data classes whose fields are Kotlin
1142/// **properties** (not Java-style getter methods). Every field segment must
1143/// therefore be accessed without parentheses: `result.choices.first().message.content`
1144/// rather than `result.choices().first().message().content()`.
1145///
1146/// The nullable-chain rules are identical to `render_kotlin_with_optionals`:
1147/// once a segment in the path is optional (`T?`) the remainder of the chain
1148/// uses `?.` safe-call syntax.
1149fn render_kotlin_android_with_optionals(
1150    segments: &[PathSegment],
1151    result_var: &str,
1152    optional_fields: &HashSet<String>,
1153) -> String {
1154    let mut out = result_var.to_string();
1155    let mut path_so_far = String::new();
1156    let mut prev_was_nullable = false;
1157    for seg in segments {
1158        let nav = if prev_was_nullable { "?." } else { "." };
1159        match seg {
1160            PathSegment::Field(f) => {
1161                if !path_so_far.is_empty() {
1162                    path_so_far.push('.');
1163                }
1164                path_so_far.push_str(f);
1165                let is_optional = optional_fields.contains(&path_so_far);
1166                out.push_str(nav);
1167                // Property access — no () suffix.
1168                out.push_str(&kotlin_getter(f));
1169                prev_was_nullable = prev_was_nullable || is_optional;
1170            }
1171            PathSegment::ArrayField { name, index } => {
1172                if !path_so_far.is_empty() {
1173                    path_so_far.push('.');
1174                }
1175                path_so_far.push_str(name);
1176                let is_optional = optional_fields.contains(&path_so_far);
1177                out.push_str(nav);
1178                // Property access — no () suffix on the collection itself.
1179                out.push_str(&kotlin_getter(name));
1180                let safe = if prev_was_nullable || is_optional { "?" } else { "" };
1181                if *index == 0 {
1182                    out.push_str(&format!("{safe}.first()"));
1183                } else {
1184                    out.push_str(&format!("{safe}.get({index})"));
1185                }
1186                path_so_far.push_str("[0]");
1187                prev_was_nullable = prev_was_nullable || is_optional;
1188            }
1189            PathSegment::MapAccess { field, key } => {
1190                if !path_so_far.is_empty() {
1191                    path_so_far.push('.');
1192                }
1193                path_so_far.push_str(field);
1194                let is_optional = optional_fields.contains(&path_so_far);
1195                out.push_str(nav);
1196                // Property access — no () suffix on the map field.
1197                out.push_str(&kotlin_getter(field));
1198                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1199                if is_numeric {
1200                    if prev_was_nullable || is_optional {
1201                        out.push_str(&format!("?.get({key})"));
1202                    } else {
1203                        out.push_str(&format!(".get({key})"));
1204                    }
1205                } else if prev_was_nullable || is_optional {
1206                    out.push_str(&format!("?.get(\"{key}\")"));
1207                } else {
1208                    out.push_str(&format!(".get(\"{key}\")"));
1209                }
1210                prev_was_nullable = prev_was_nullable || is_optional;
1211            }
1212            PathSegment::Length => {
1213                let size_nav = if prev_was_nullable { "?" } else { "" };
1214                out.push_str(&format!("{size_nav}.size"));
1215                prev_was_nullable = false;
1216            }
1217        }
1218    }
1219    out
1220}
1221
1222/// Non-optional variant of `render_kotlin_android_with_optionals`.
1223///
1224/// Used by `render_accessor` (the path without per-field optionality tracking).
1225fn render_kotlin_android(segments: &[PathSegment], result_var: &str) -> String {
1226    let mut out = result_var.to_string();
1227    for seg in segments {
1228        match seg {
1229            PathSegment::Field(f) => {
1230                out.push('.');
1231                out.push_str(&kotlin_getter(f));
1232                // No () — property access.
1233            }
1234            PathSegment::ArrayField { name, index } => {
1235                out.push('.');
1236                out.push_str(&kotlin_getter(name));
1237                if *index == 0 {
1238                    out.push_str(".first()");
1239                } else {
1240                    out.push_str(&format!(".get({index})"));
1241                }
1242            }
1243            PathSegment::MapAccess { field, key } => {
1244                out.push('.');
1245                out.push_str(&kotlin_getter(field));
1246                let is_numeric = !key.is_empty() && key.chars().all(|c| c.is_ascii_digit());
1247                if is_numeric {
1248                    out.push_str(&format!(".get({key})"));
1249                } else {
1250                    out.push_str(&format!(".get(\"{key}\")"));
1251                }
1252            }
1253            PathSegment::Length => {
1254                out.push_str(".size");
1255            }
1256        }
1257    }
1258    out
1259}
1260
1261/// Rust accessor with Option unwrapping for intermediate fields.
1262///
1263/// When an intermediate field is in the `optional_fields` set, `.as_ref().unwrap()`
1264/// is appended after the field access to unwrap the `Option<T>`.
1265/// When a path is in `method_calls`, `()` is appended to make it a method call.
1266fn render_rust_with_optionals(
1267    segments: &[PathSegment],
1268    result_var: &str,
1269    optional_fields: &HashSet<String>,
1270    method_calls: &HashSet<String>,
1271) -> String {
1272    let mut out = result_var.to_string();
1273    let mut path_so_far = String::new();
1274    for (i, seg) in segments.iter().enumerate() {
1275        let is_leaf = i == segments.len() - 1;
1276        match seg {
1277            PathSegment::Field(f) => {
1278                if !path_so_far.is_empty() {
1279                    path_so_far.push('.');
1280                }
1281                path_so_far.push_str(f);
1282                out.push('.');
1283                out.push_str(&f.to_snake_case());
1284                let is_method = method_calls.contains(&path_so_far);
1285                if is_method {
1286                    out.push_str("()");
1287                    if !is_leaf && optional_fields.contains(&path_so_far) {
1288                        out.push_str(".as_ref().unwrap()");
1289                    }
1290                } else if !is_leaf && optional_fields.contains(&path_so_far) {
1291                    out.push_str(".as_ref().unwrap()");
1292                }
1293            }
1294            PathSegment::ArrayField { name, index } => {
1295                if !path_so_far.is_empty() {
1296                    path_so_far.push('.');
1297                }
1298                path_so_far.push_str(name);
1299                out.push('.');
1300                out.push_str(&name.to_snake_case());
1301                // Option<Vec<T>>: must unwrap the Option before indexing.
1302                // Check both "name" (bare) and "name[0]" (indexed) forms since the
1303                // optional_fields registry may use either convention.
1304                let path_with_idx = format!("{path_so_far}[0]");
1305                let is_opt = optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1306                if is_opt {
1307                    out.push_str(&format!(".as_ref().unwrap()[{index}]"));
1308                } else {
1309                    out.push_str(&format!("[{index}]"));
1310                }
1311                // Record the normalised "[0]" suffix in path_so_far so that deeper
1312                // optional-field keys which include explicit indices (e.g.
1313                // "choices[0].message.tool_calls") continue to match when we check
1314                // subsequent segments.
1315                path_so_far.push_str("[0]");
1316            }
1317            PathSegment::MapAccess { field, key } => {
1318                if !path_so_far.is_empty() {
1319                    path_so_far.push('.');
1320                }
1321                path_so_far.push_str(field);
1322                out.push('.');
1323                out.push_str(&field.to_snake_case());
1324                if key.chars().all(|c| c.is_ascii_digit()) {
1325                    // Check optional both with and without the numeric index suffix.
1326                    let path_with_idx = format!("{path_so_far}[0]");
1327                    let is_opt =
1328                        optional_fields.contains(&path_so_far) || optional_fields.contains(path_with_idx.as_str());
1329                    if is_opt {
1330                        out.push_str(&format!(".as_ref().unwrap()[{key}]"));
1331                    } else {
1332                        out.push_str(&format!("[{key}]"));
1333                    }
1334                    path_so_far.push_str("[0]");
1335                } else {
1336                    out.push_str(&format!(".get(\"{key}\").map(|s| s.as_str())"));
1337                }
1338            }
1339            PathSegment::Length => {
1340                out.push_str(".len()");
1341            }
1342        }
1343    }
1344    out
1345}
1346
1347/// Zig accessor that unwraps optional fields with `.?`.
1348///
1349/// Zig does not allow field access, indexing, or comparisons through `?T`;
1350/// the value must be unwrapped first. Each segment whose path appears in the
1351/// optional-field set is followed by `.?` so the resulting expression is a
1352/// concrete value usable in assertions.
1353///
1354/// Paths in `method_calls` represent tagged-union variant accessors (Rust
1355/// variant getters such as `FormatMetadata::excel()`). In Zig, tagged-union
1356/// variants are accessed via the same dot syntax as struct fields, so the
1357/// segment is emitted as `.{name}` *without* `.?` even if the path also
1358/// appears in `optional_fields`.
1359fn render_zig_with_optionals(
1360    segments: &[PathSegment],
1361    result_var: &str,
1362    optional_fields: &HashSet<String>,
1363    method_calls: &HashSet<String>,
1364) -> String {
1365    let mut out = result_var.to_string();
1366    let mut path_so_far = String::new();
1367    for seg in segments {
1368        match seg {
1369            PathSegment::Field(f) => {
1370                if !path_so_far.is_empty() {
1371                    path_so_far.push('.');
1372                }
1373                path_so_far.push_str(f);
1374                out.push('.');
1375                out.push_str(f);
1376                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1377                    out.push_str(".?");
1378                }
1379            }
1380            PathSegment::ArrayField { name, index } => {
1381                if !path_so_far.is_empty() {
1382                    path_so_far.push('.');
1383                }
1384                path_so_far.push_str(name);
1385                out.push('.');
1386                out.push_str(name);
1387                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1388                    out.push_str(".?");
1389                }
1390                out.push_str(&format!("[{index}]"));
1391            }
1392            PathSegment::MapAccess { field, key } => {
1393                if !path_so_far.is_empty() {
1394                    path_so_far.push('.');
1395                }
1396                path_so_far.push_str(field);
1397                out.push('.');
1398                out.push_str(field);
1399                if !method_calls.contains(&path_so_far) && optional_fields.contains(&path_so_far) {
1400                    out.push_str(".?");
1401                }
1402                if key.chars().all(|c| c.is_ascii_digit()) {
1403                    out.push_str(&format!("[{key}]"));
1404                } else {
1405                    out.push_str(&format!(".get(\"{key}\")"));
1406                }
1407            }
1408            PathSegment::Length => {
1409                out.push_str(".len");
1410            }
1411        }
1412    }
1413    out
1414}
1415
1416fn render_pascal_dot(segments: &[PathSegment], result_var: &str) -> String {
1417    let mut out = result_var.to_string();
1418    for seg in segments {
1419        match seg {
1420            PathSegment::Field(f) => {
1421                out.push('.');
1422                out.push_str(&f.to_pascal_case());
1423            }
1424            PathSegment::ArrayField { name, index } => {
1425                out.push('.');
1426                out.push_str(&name.to_pascal_case());
1427                out.push_str(&format!("[{index}]"));
1428            }
1429            PathSegment::MapAccess { field, key } => {
1430                out.push('.');
1431                out.push_str(&field.to_pascal_case());
1432                if key.chars().all(|c| c.is_ascii_digit()) {
1433                    out.push_str(&format!("[{key}]"));
1434                } else {
1435                    out.push_str(&format!("[\"{key}\"]"));
1436                }
1437            }
1438            PathSegment::Length => {
1439                out.push_str(".Count");
1440            }
1441        }
1442    }
1443    out
1444}
1445
1446fn render_csharp_with_optionals(
1447    segments: &[PathSegment],
1448    result_var: &str,
1449    optional_fields: &HashSet<String>,
1450) -> String {
1451    let mut out = result_var.to_string();
1452    let mut path_so_far = String::new();
1453    for (i, seg) in segments.iter().enumerate() {
1454        let is_leaf = i == segments.len() - 1;
1455        match seg {
1456            PathSegment::Field(f) => {
1457                if !path_so_far.is_empty() {
1458                    path_so_far.push('.');
1459                }
1460                path_so_far.push_str(f);
1461                out.push('.');
1462                out.push_str(&f.to_pascal_case());
1463                if !is_leaf && optional_fields.contains(&path_so_far) {
1464                    out.push('!');
1465                }
1466            }
1467            PathSegment::ArrayField { name, index } => {
1468                if !path_so_far.is_empty() {
1469                    path_so_far.push('.');
1470                }
1471                path_so_far.push_str(name);
1472                out.push('.');
1473                out.push_str(&name.to_pascal_case());
1474                out.push_str(&format!("[{index}]"));
1475            }
1476            PathSegment::MapAccess { field, key } => {
1477                if !path_so_far.is_empty() {
1478                    path_so_far.push('.');
1479                }
1480                path_so_far.push_str(field);
1481                out.push('.');
1482                out.push_str(&field.to_pascal_case());
1483                if key.chars().all(|c| c.is_ascii_digit()) {
1484                    out.push_str(&format!("[{key}]"));
1485                } else {
1486                    out.push_str(&format!("[\"{key}\"]"));
1487                }
1488            }
1489            PathSegment::Length => {
1490                out.push_str(".Count");
1491            }
1492        }
1493    }
1494    out
1495}
1496
1497fn render_php(segments: &[PathSegment], result_var: &str) -> String {
1498    let mut out = result_var.to_string();
1499    for seg in segments {
1500        match seg {
1501            PathSegment::Field(f) => {
1502                out.push_str("->");
1503                // PHP properties are camelCase (per #[php(prop, name = "...")]),
1504                // so convert snake_case field names to camelCase.
1505                out.push_str(&f.to_lower_camel_case());
1506            }
1507            PathSegment::ArrayField { name, index } => {
1508                out.push_str("->");
1509                out.push_str(&name.to_lower_camel_case());
1510                out.push_str(&format!("[{index}]"));
1511            }
1512            PathSegment::MapAccess { field, key } => {
1513                out.push_str("->");
1514                out.push_str(&field.to_lower_camel_case());
1515                out.push_str(&format!("[\"{key}\"]"));
1516            }
1517            PathSegment::Length => {
1518                let current = std::mem::take(&mut out);
1519                out = format!("count({current})");
1520            }
1521        }
1522    }
1523    out
1524}
1525
1526/// PHP accessor that distinguishes between scalar fields (property access: `->camelCase`)
1527/// and non-scalar fields (getter-method access: `->getCamelCase()`).
1528///
1529/// ext-php-rs 0.15.x exposes scalar fields via `#[php(prop)]` as PHP properties, but
1530/// non-scalar fields (Named structs, `Vec<Named>`, `Map`, etc.) require a `#[php(getter)]`
1531/// method because `get_method_props` is `todo!()` in ext-php-rs-derive 0.11.7.
1532/// The generated getter method name is `get{CamelCase}` (stripping the `get_` prefix and
1533/// converting the camelCase remainder to a PHP property name), so e2e assertions must call
1534/// `->getCamelCase()` for those fields.
1535///
1536/// `getter_map` carries the per-`(owner_type, field_name)` classification along with the
1537/// chain-resolution metadata required to walk multi-segment paths through the IR's nested
1538/// type graph. Each path segment is classified using the *current* owner type, then the
1539/// owner cursor advances to the field's referenced `Named` type (if any) for the next
1540/// segment. When `root_type` is unset the renderer falls back to the legacy bare-name
1541/// union, which is unsafe but preserves backwards compatibility for callers that have
1542/// not wired type resolution.
1543fn render_php_with_getters(segments: &[PathSegment], result_var: &str, getter_map: &PhpGetterMap) -> String {
1544    let mut out = result_var.to_string();
1545    let mut current_type: Option<String> = getter_map.root_type.clone();
1546    for seg in segments {
1547        match seg {
1548            PathSegment::Field(f) => {
1549                let camel = f.to_lower_camel_case();
1550                if getter_map.needs_getter(current_type.as_deref(), f.as_str()) {
1551                    // Non-scalar field: ext-php-rs emits a `get{CamelCase}()` method.
1552                    // The `get_` prefix is stripped by ext-php-rs when it derives the
1553                    // PHP property name, but the Rust method ident is `get_{camelCase}`,
1554                    // so the PHP call is `->get{CamelCase}()`.
1555                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1556                    out.push_str("->");
1557                    out.push_str(&getter);
1558                    out.push_str("()");
1559                } else {
1560                    out.push_str("->");
1561                    out.push_str(&camel);
1562                }
1563                current_type = getter_map.advance(current_type.as_deref(), f.as_str());
1564            }
1565            PathSegment::ArrayField { name, index } => {
1566                let camel = name.to_lower_camel_case();
1567                if getter_map.needs_getter(current_type.as_deref(), name.as_str()) {
1568                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1569                    out.push_str("->");
1570                    out.push_str(&getter);
1571                    out.push_str("()");
1572                } else {
1573                    out.push_str("->");
1574                    out.push_str(&camel);
1575                }
1576                out.push_str(&format!("[{index}]"));
1577                current_type = getter_map.advance(current_type.as_deref(), name.as_str());
1578            }
1579            PathSegment::MapAccess { field, key } => {
1580                let camel = field.to_lower_camel_case();
1581                if getter_map.needs_getter(current_type.as_deref(), field.as_str()) {
1582                    let getter = format!("get{}", camel.as_str()[..1].to_uppercase() + &camel[1..]);
1583                    out.push_str("->");
1584                    out.push_str(&getter);
1585                    out.push_str("()");
1586                } else {
1587                    out.push_str("->");
1588                    out.push_str(&camel);
1589                }
1590                out.push_str(&format!("[\"{key}\"]"));
1591                current_type = getter_map.advance(current_type.as_deref(), field.as_str());
1592            }
1593            PathSegment::Length => {
1594                let current = std::mem::take(&mut out);
1595                out = format!("count({current})");
1596            }
1597        }
1598    }
1599    out
1600}
1601
1602fn render_r(segments: &[PathSegment], result_var: &str) -> String {
1603    let mut out = result_var.to_string();
1604    for seg in segments {
1605        match seg {
1606            PathSegment::Field(f) => {
1607                out.push('$');
1608                out.push_str(f);
1609            }
1610            PathSegment::ArrayField { name, index } => {
1611                out.push('$');
1612                out.push_str(name);
1613                // R uses 1-based indexing.
1614                out.push_str(&format!("[[{}]]", index + 1));
1615            }
1616            PathSegment::MapAccess { field, key } => {
1617                out.push('$');
1618                out.push_str(field);
1619                out.push_str(&format!("[[\"{key}\"]]"));
1620            }
1621            PathSegment::Length => {
1622                let current = std::mem::take(&mut out);
1623                out = format!("length({current})");
1624            }
1625        }
1626    }
1627    out
1628}
1629
1630fn render_c(segments: &[PathSegment], result_var: &str) -> String {
1631    let mut parts = Vec::new();
1632    let mut trailing_length = false;
1633    for seg in segments {
1634        match seg {
1635            PathSegment::Field(f) => parts.push(f.to_snake_case()),
1636            PathSegment::ArrayField { name, .. } => parts.push(name.to_snake_case()),
1637            PathSegment::MapAccess { field, key } => {
1638                parts.push(field.to_snake_case());
1639                parts.push(key.clone());
1640            }
1641            PathSegment::Length => {
1642                trailing_length = true;
1643            }
1644        }
1645    }
1646    let suffix = parts.join("_");
1647    if trailing_length {
1648        format!("result_{suffix}_count({result_var})")
1649    } else {
1650        format!("result_{suffix}({result_var})")
1651    }
1652}
1653
1654/// Dart accessor using camelCase field names (FRB v2 convention).
1655///
1656/// FRB v2 generates Dart property getters with camelCase names for every
1657/// snake_case Rust field, so `snake_case_field` becomes `snakeCaseField`.
1658/// Array fields index with `[N]`; map fields use `["key"]` or `[N]` notation.
1659/// Length/count segments use `.length` (Dart `List.length`).
1660fn render_dart(segments: &[PathSegment], result_var: &str) -> String {
1661    let mut out = result_var.to_string();
1662    for seg in segments {
1663        match seg {
1664            PathSegment::Field(f) => {
1665                out.push('.');
1666                out.push_str(&f.to_lower_camel_case());
1667            }
1668            PathSegment::ArrayField { name, index } => {
1669                out.push('.');
1670                out.push_str(&name.to_lower_camel_case());
1671                out.push_str(&format!("[{index}]"));
1672            }
1673            PathSegment::MapAccess { field, key } => {
1674                out.push('.');
1675                out.push_str(&field.to_lower_camel_case());
1676                if key.chars().all(|c| c.is_ascii_digit()) {
1677                    out.push_str(&format!("[{key}]"));
1678                } else {
1679                    out.push_str(&format!("[\"{key}\"]"));
1680                }
1681            }
1682            PathSegment::Length => {
1683                out.push_str(".length");
1684            }
1685        }
1686    }
1687    out
1688}
1689
1690/// Dart accessor with optional-safe navigation using `?.` (FRB v2 convention).
1691///
1692/// When an intermediate field is in `optional_fields`, the next segment uses
1693/// `?.` safe-call navigation instead of `.` to avoid a null-dereference on
1694/// a nullable Dart type.  Field names are camelCase (FRB v2 generation rule).
1695fn render_dart_with_optionals(segments: &[PathSegment], result_var: &str, optional_fields: &HashSet<String>) -> String {
1696    let mut out = result_var.to_string();
1697    let mut path_so_far = String::new();
1698    let mut prev_was_nullable = false;
1699    for seg in segments {
1700        let nav = if prev_was_nullable { "?." } else { "." };
1701        match seg {
1702            PathSegment::Field(f) => {
1703                if !path_so_far.is_empty() {
1704                    path_so_far.push('.');
1705                }
1706                path_so_far.push_str(f);
1707                let is_optional = optional_fields.contains(&path_so_far);
1708                out.push_str(nav);
1709                out.push_str(&f.to_lower_camel_case());
1710                prev_was_nullable = is_optional;
1711            }
1712            PathSegment::ArrayField { name, index } => {
1713                if !path_so_far.is_empty() {
1714                    path_so_far.push('.');
1715                }
1716                path_so_far.push_str(name);
1717                let is_optional = optional_fields.contains(&path_so_far);
1718                out.push_str(nav);
1719                out.push_str(&name.to_lower_camel_case());
1720                // FRB models `Option<Vec<T>>` as `List<T>?` — only force-unwrap when the field
1721                // is registered as optional. Adding `!` to a non-nullable receiver is a Dart
1722                // compile-time error ("unnecessary non-null assertion").
1723                if is_optional {
1724                    out.push('!');
1725                }
1726                out.push_str(&format!("[{index}]"));
1727                prev_was_nullable = false;
1728            }
1729            PathSegment::MapAccess { field, key } => {
1730                if !path_so_far.is_empty() {
1731                    path_so_far.push('.');
1732                }
1733                path_so_far.push_str(field);
1734                let is_optional = optional_fields.contains(&path_so_far);
1735                out.push_str(nav);
1736                out.push_str(&field.to_lower_camel_case());
1737                if key.chars().all(|c| c.is_ascii_digit()) {
1738                    out.push_str(&format!("[{key}]"));
1739                } else {
1740                    out.push_str(&format!("[\"{key}\"]"));
1741                }
1742                prev_was_nullable = is_optional;
1743            }
1744            PathSegment::Length => {
1745                // Use `?.length` when the receiver is optional — emitting `.length` against
1746                // a `List<T>?` is a Dart sound-null-safety error.
1747                out.push_str(nav);
1748                out.push_str("length");
1749                prev_was_nullable = false;
1750            }
1751        }
1752    }
1753    out
1754}
1755
1756#[cfg(test)]
1757mod tests {
1758    use super::*;
1759
1760    fn make_resolver() -> FieldResolver {
1761        let mut fields = HashMap::new();
1762        fields.insert("title".to_string(), "metadata.document.title".to_string());
1763        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1764        fields.insert("og".to_string(), "metadata.document.open_graph".to_string());
1765        fields.insert("twitter".to_string(), "metadata.document.twitter_card".to_string());
1766        fields.insert("canonical".to_string(), "metadata.document.canonical_url".to_string());
1767        fields.insert("og_tag".to_string(), "metadata.open_graph_tags[og_title]".to_string());
1768        let mut optional = HashSet::new();
1769        optional.insert("metadata.document.title".to_string());
1770        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1771    }
1772
1773    fn make_resolver_with_doc_optional() -> FieldResolver {
1774        let mut fields = HashMap::new();
1775        fields.insert("title".to_string(), "metadata.document.title".to_string());
1776        fields.insert("tags".to_string(), "metadata.tags[name]".to_string());
1777        let mut optional = HashSet::new();
1778        optional.insert("document".to_string());
1779        optional.insert("metadata.document.title".to_string());
1780        optional.insert("metadata.document".to_string());
1781        FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &HashSet::new())
1782    }
1783
1784    #[test]
1785    fn test_resolve_alias() {
1786        let r = make_resolver();
1787        assert_eq!(r.resolve("title"), "metadata.document.title");
1788    }
1789
1790    #[test]
1791    fn test_resolve_passthrough() {
1792        let r = make_resolver();
1793        assert_eq!(r.resolve("content"), "content");
1794    }
1795
1796    #[test]
1797    fn test_is_optional() {
1798        let r = make_resolver();
1799        assert!(r.is_optional("metadata.document.title"));
1800        assert!(!r.is_optional("content"));
1801    }
1802
1803    #[test]
1804    fn test_accessor_rust_struct() {
1805        let r = make_resolver();
1806        assert_eq!(r.accessor("title", "rust", "result"), "result.metadata.document.title");
1807    }
1808
1809    #[test]
1810    fn test_accessor_rust_map() {
1811        let r = make_resolver();
1812        assert_eq!(
1813            r.accessor("tags", "rust", "result"),
1814            "result.metadata.tags.get(\"name\").map(|s| s.as_str())"
1815        );
1816    }
1817
1818    #[test]
1819    fn test_accessor_python() {
1820        let r = make_resolver();
1821        assert_eq!(
1822            r.accessor("title", "python", "result"),
1823            "result.metadata.document.title"
1824        );
1825    }
1826
1827    #[test]
1828    fn test_accessor_go() {
1829        let r = make_resolver();
1830        assert_eq!(r.accessor("title", "go", "result"), "result.Metadata.Document.Title");
1831    }
1832
1833    #[test]
1834    fn test_accessor_go_initialism_fields() {
1835        let mut fields = std::collections::HashMap::new();
1836        fields.insert("content".to_string(), "html".to_string());
1837        fields.insert("link_url".to_string(), "links.url".to_string());
1838        let r = FieldResolver::new(
1839            &fields,
1840            &HashSet::new(),
1841            &HashSet::new(),
1842            &HashSet::new(),
1843            &HashSet::new(),
1844        );
1845        assert_eq!(r.accessor("content", "go", "result"), "result.HTML");
1846        assert_eq!(r.accessor("link_url", "go", "result"), "result.Links.URL");
1847        assert_eq!(r.accessor("html", "go", "result"), "result.HTML");
1848        assert_eq!(r.accessor("url", "go", "result"), "result.URL");
1849        assert_eq!(r.accessor("id", "go", "result"), "result.ID");
1850        assert_eq!(r.accessor("user_id", "go", "result"), "result.UserID");
1851        assert_eq!(r.accessor("request_url", "go", "result"), "result.RequestURL");
1852        assert_eq!(r.accessor("links", "go", "result"), "result.Links");
1853    }
1854
1855    #[test]
1856    fn test_accessor_typescript() {
1857        let r = make_resolver();
1858        assert_eq!(
1859            r.accessor("title", "typescript", "result"),
1860            "result.metadata.document.title"
1861        );
1862    }
1863
1864    #[test]
1865    fn test_accessor_typescript_snake_to_camel() {
1866        let r = make_resolver();
1867        assert_eq!(
1868            r.accessor("og", "typescript", "result"),
1869            "result.metadata.document.openGraph"
1870        );
1871        assert_eq!(
1872            r.accessor("twitter", "typescript", "result"),
1873            "result.metadata.document.twitterCard"
1874        );
1875        assert_eq!(
1876            r.accessor("canonical", "typescript", "result"),
1877            "result.metadata.document.canonicalUrl"
1878        );
1879    }
1880
1881    #[test]
1882    fn test_accessor_typescript_map_snake_to_camel() {
1883        let r = make_resolver();
1884        assert_eq!(
1885            r.accessor("og_tag", "typescript", "result"),
1886            "result.metadata.openGraphTags[\"og_title\"]"
1887        );
1888    }
1889
1890    #[test]
1891    fn test_accessor_typescript_numeric_index_is_unquoted() {
1892        // Digit-only map-access keys (e.g. JSON pointer segments like `results.0`)
1893        // must emit numeric bracket access (`[0]`) not string-keyed access
1894        // (`["0"]`), which would return undefined on arrays.
1895        let mut fields = HashMap::new();
1896        fields.insert("first_score".to_string(), "results[0].relevance_score".to_string());
1897        let r = FieldResolver::new(
1898            &fields,
1899            &HashSet::new(),
1900            &HashSet::new(),
1901            &HashSet::new(),
1902            &HashSet::new(),
1903        );
1904        assert_eq!(
1905            r.accessor("first_score", "typescript", "result"),
1906            "result.results[0].relevanceScore"
1907        );
1908    }
1909
1910    #[test]
1911    fn test_accessor_node_alias() {
1912        let r = make_resolver();
1913        assert_eq!(r.accessor("og", "node", "result"), "result.metadata.document.openGraph");
1914    }
1915
1916    #[test]
1917    fn test_accessor_wasm_camel_case() {
1918        let r = make_resolver();
1919        assert_eq!(r.accessor("og", "wasm", "result"), "result.metadata.document.openGraph");
1920        assert_eq!(
1921            r.accessor("twitter", "wasm", "result"),
1922            "result.metadata.document.twitterCard"
1923        );
1924        assert_eq!(
1925            r.accessor("canonical", "wasm", "result"),
1926            "result.metadata.document.canonicalUrl"
1927        );
1928    }
1929
1930    #[test]
1931    fn test_accessor_wasm_map_access() {
1932        let r = make_resolver();
1933        assert_eq!(
1934            r.accessor("og_tag", "wasm", "result"),
1935            "result.metadata.openGraphTags.get(\"og_title\")"
1936        );
1937    }
1938
1939    #[test]
1940    fn test_accessor_java() {
1941        let r = make_resolver();
1942        assert_eq!(
1943            r.accessor("title", "java", "result"),
1944            "result.metadata().document().title()"
1945        );
1946    }
1947
1948    #[test]
1949    fn test_accessor_kotlin_uses_kotlin_collection_idioms() {
1950        let mut fields = HashMap::new();
1951        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
1952        fields.insert("node_count".to_string(), "nodes.length".to_string());
1953        let mut arrays = HashSet::new();
1954        arrays.insert("nodes".to_string());
1955        let r = FieldResolver::new(&fields, &HashSet::new(), &HashSet::new(), &arrays, &HashSet::new());
1956        assert_eq!(
1957            r.accessor("first_node_name", "kotlin", "result"),
1958            "result.nodes().first().name()"
1959        );
1960        assert_eq!(r.accessor("node_count", "kotlin", "result"), "result.nodes().size");
1961    }
1962
1963    #[test]
1964    fn test_accessor_kotlin_uses_safe_calls_for_optional_prefixes() {
1965        let r = make_resolver_with_doc_optional();
1966        assert_eq!(
1967            r.accessor("title", "kotlin", "result"),
1968            "result.metadata().document()?.title()"
1969        );
1970    }
1971
1972    #[test]
1973    fn test_accessor_kotlin_uses_safe_calls_for_optional_arrays_and_maps() {
1974        let mut fields = HashMap::new();
1975        fields.insert("first_node_name".to_string(), "nodes[0].name".to_string());
1976        fields.insert("tag".to_string(), "tags[name]".to_string());
1977        let mut optional = HashSet::new();
1978        optional.insert("nodes".to_string());
1979        optional.insert("tags".to_string());
1980        let mut arrays = HashSet::new();
1981        arrays.insert("nodes".to_string());
1982        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
1983        assert_eq!(
1984            r.accessor("first_node_name", "kotlin", "result"),
1985            "result.nodes()?.first()?.name()"
1986        );
1987        assert_eq!(r.accessor("tag", "kotlin", "result"), "result.tags()?.get(\"name\")");
1988    }
1989
1990    /// Regression: optional-field keys with explicit `[0]` indices (e.g.
1991    /// `"choices[0].message.tool_calls"`) were not matched by
1992    /// `render_kotlin_with_optionals` because `path_so_far` omitted the `[0]`
1993    /// suffix after traversing an ArrayField segment. Fix: append `"[0]"` to
1994    /// `path_so_far` after each ArrayField, mirroring the Rust renderer.
1995    #[test]
1996    fn test_accessor_kotlin_optional_field_after_indexed_array() {
1997        // "choices[0].message.tool_calls" is optional; the path is accessed as
1998        // choices[0].message.tool_calls[0].function.name.
1999        let mut fields = HashMap::new();
2000        fields.insert(
2001            "tool_call_name".to_string(),
2002            "choices[0].message.tool_calls[0].function.name".to_string(),
2003        );
2004        let mut optional = HashSet::new();
2005        optional.insert("choices[0].message.tool_calls".to_string());
2006        let mut arrays = HashSet::new();
2007        arrays.insert("choices".to_string());
2008        arrays.insert("choices[0].message.tool_calls".to_string());
2009        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &arrays, &HashSet::new());
2010        let expr = r.accessor("tool_call_name", "kotlin", "result");
2011        // toolCalls() is optional so it must use `?.` before `.first()`.
2012        assert!(
2013            expr.contains("toolCalls()?.first()"),
2014            "expected toolCalls()?.first() for optional list, got: {expr}"
2015        );
2016    }
2017
2018    #[test]
2019    fn test_accessor_csharp() {
2020        let r = make_resolver();
2021        assert_eq!(
2022            r.accessor("title", "csharp", "result"),
2023            "result.Metadata.Document.Title"
2024        );
2025    }
2026
2027    #[test]
2028    fn test_accessor_php() {
2029        let r = make_resolver();
2030        assert_eq!(
2031            r.accessor("title", "php", "$result"),
2032            "$result->metadata->document->title"
2033        );
2034    }
2035
2036    #[test]
2037    fn test_accessor_r() {
2038        let r = make_resolver();
2039        assert_eq!(r.accessor("title", "r", "result"), "result$metadata$document$title");
2040    }
2041
2042    #[test]
2043    fn test_accessor_c() {
2044        let r = make_resolver();
2045        assert_eq!(
2046            r.accessor("title", "c", "result"),
2047            "result_metadata_document_title(result)"
2048        );
2049    }
2050
2051    #[test]
2052    fn test_rust_unwrap_binding() {
2053        let r = make_resolver();
2054        let (binding, var) = r.rust_unwrap_binding("title", "result").unwrap();
2055        assert_eq!(var, "metadata_document_title");
2056        assert!(binding.contains("as_ref().map(|v| v.to_string()).unwrap_or_default()"));
2057    }
2058
2059    #[test]
2060    fn test_rust_unwrap_binding_non_optional() {
2061        let r = make_resolver();
2062        assert!(r.rust_unwrap_binding("content", "result").is_none());
2063    }
2064
2065    #[test]
2066    fn test_rust_unwrap_binding_collapses_double_underscore() {
2067        // When an alias resolves to a path with `[]` (e.g. `json_ld.name` →
2068        // `json_ld[].name`), the naive replace previously yielded `json_ld__name`,
2069        // which trips Rust's non_snake_case lint under -D warnings. The local
2070        // binding name must collapse consecutive underscores into one.
2071        let mut aliases = HashMap::new();
2072        aliases.insert("json_ld.name".to_string(), "json_ld[].name".to_string());
2073        let mut optional = HashSet::new();
2074        optional.insert("json_ld[].name".to_string());
2075        let mut array = HashSet::new();
2076        array.insert("json_ld".to_string());
2077        let result_fields = HashSet::new();
2078        let method_calls = HashSet::new();
2079        let r = FieldResolver::new(&aliases, &optional, &result_fields, &array, &method_calls);
2080        let (_binding, var) = r.rust_unwrap_binding("json_ld.name", "result").unwrap();
2081        assert_eq!(var, "json_ld_name");
2082    }
2083
2084    #[test]
2085    fn test_direct_field_no_alias() {
2086        let r = make_resolver();
2087        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2088        assert_eq!(r.accessor("content", "go", "result"), "result.Content");
2089    }
2090
2091    #[test]
2092    fn test_accessor_rust_with_optionals() {
2093        let r = make_resolver_with_doc_optional();
2094        assert_eq!(
2095            r.accessor("title", "rust", "result"),
2096            "result.metadata.document.as_ref().unwrap().title"
2097        );
2098    }
2099
2100    #[test]
2101    fn test_accessor_csharp_with_optionals() {
2102        let r = make_resolver_with_doc_optional();
2103        assert_eq!(
2104            r.accessor("title", "csharp", "result"),
2105            "result.Metadata.Document!.Title"
2106        );
2107    }
2108
2109    #[test]
2110    fn test_accessor_rust_non_optional_field() {
2111        let r = make_resolver();
2112        assert_eq!(r.accessor("content", "rust", "result"), "result.content");
2113    }
2114
2115    #[test]
2116    fn test_accessor_csharp_non_optional_field() {
2117        let r = make_resolver();
2118        assert_eq!(r.accessor("content", "csharp", "result"), "result.Content");
2119    }
2120
2121    #[test]
2122    fn test_accessor_rust_method_call() {
2123        // "metadata.format.excel" is in method_calls — should emit `excel()` instead of `excel`
2124        let mut fields = HashMap::new();
2125        fields.insert(
2126            "excel_sheet_count".to_string(),
2127            "metadata.format.excel.sheet_count".to_string(),
2128        );
2129        let mut optional = HashSet::new();
2130        optional.insert("metadata.format".to_string());
2131        optional.insert("metadata.format.excel".to_string());
2132        let mut method_calls = HashSet::new();
2133        method_calls.insert("metadata.format.excel".to_string());
2134        let r = FieldResolver::new(&fields, &optional, &HashSet::new(), &HashSet::new(), &method_calls);
2135        assert_eq!(
2136            r.accessor("excel_sheet_count", "rust", "result"),
2137            "result.metadata.format.as_ref().unwrap().excel().as_ref().unwrap().sheet_count"
2138        );
2139    }
2140
2141    // ---------------------------------------------------------------------------
2142    // PHP getter-method tests (ext-php-rs 0.15.x `#[php(getter)]` vs `#[php(prop)]`)
2143    // ---------------------------------------------------------------------------
2144
2145    fn make_php_getter_resolver() -> FieldResolver {
2146        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2147        getters.insert(
2148            "Root".to_string(),
2149            ["metadata".to_string(), "links".to_string()].into_iter().collect(),
2150        );
2151        let map = PhpGetterMap {
2152            getters,
2153            field_types: HashMap::new(),
2154            root_type: Some("Root".to_string()),
2155            all_fields: HashMap::new(),
2156        };
2157        FieldResolver::new_with_php_getters(
2158            &HashMap::new(),
2159            &HashSet::new(),
2160            &HashSet::new(),
2161            &HashSet::new(),
2162            &HashSet::new(),
2163            &HashMap::new(),
2164            map,
2165        )
2166    }
2167
2168    #[test]
2169    fn render_php_uses_getter_method_for_non_scalar_field() {
2170        let r = make_php_getter_resolver();
2171        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->getMetadata()");
2172    }
2173
2174    #[test]
2175    fn render_php_uses_property_for_scalar_field() {
2176        let r = make_php_getter_resolver();
2177        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2178    }
2179
2180    #[test]
2181    fn render_php_nested_non_scalar_uses_getter_then_property() {
2182        let mut fields = HashMap::new();
2183        fields.insert("title".to_string(), "metadata.title".to_string());
2184        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2185        getters.insert("Root".to_string(), ["metadata".to_string()].into_iter().collect());
2186        // No entry for Metadata.title → scalar by default.
2187        getters.insert("Metadata".to_string(), HashSet::new());
2188        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2189        field_types.insert(
2190            "Root".to_string(),
2191            [("metadata".to_string(), "Metadata".to_string())].into_iter().collect(),
2192        );
2193        let map = PhpGetterMap {
2194            getters,
2195            field_types,
2196            root_type: Some("Root".to_string()),
2197            all_fields: HashMap::new(),
2198        };
2199        let r = FieldResolver::new_with_php_getters(
2200            &fields,
2201            &HashSet::new(),
2202            &HashSet::new(),
2203            &HashSet::new(),
2204            &HashSet::new(),
2205            &HashMap::new(),
2206            map,
2207        );
2208        // `metadata` → `->getMetadata()`, then `title` (scalar on returned object) → `->title`
2209        assert_eq!(r.accessor("title", "php", "$result"), "$result->getMetadata()->title");
2210    }
2211
2212    #[test]
2213    fn render_php_array_field_uses_getter_when_non_scalar() {
2214        let mut fields = HashMap::new();
2215        fields.insert("first_link".to_string(), "links[0]".to_string());
2216        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2217        getters.insert("Root".to_string(), ["links".to_string()].into_iter().collect());
2218        let map = PhpGetterMap {
2219            getters,
2220            field_types: HashMap::new(),
2221            root_type: Some("Root".to_string()),
2222            all_fields: HashMap::new(),
2223        };
2224        let r = FieldResolver::new_with_php_getters(
2225            &fields,
2226            &HashSet::new(),
2227            &HashSet::new(),
2228            &HashSet::new(),
2229            &HashSet::new(),
2230            &HashMap::new(),
2231            map,
2232        );
2233        assert_eq!(r.accessor("first_link", "php", "$result"), "$result->getLinks()[0]");
2234    }
2235
2236    #[test]
2237    fn render_php_falls_back_to_property_when_getter_fields_empty() {
2238        // With empty php_getter_map the resolver uses the plain `render_php` path,
2239        // which emits `->camelCase` for every field regardless of scalar-ness.
2240        let r = FieldResolver::new(
2241            &HashMap::new(),
2242            &HashSet::new(),
2243            &HashSet::new(),
2244            &HashSet::new(),
2245            &HashSet::new(),
2246        );
2247        assert_eq!(r.accessor("status_code", "php", "$result"), "$result->statusCode");
2248        assert_eq!(r.accessor("metadata", "php", "$result"), "$result->metadata");
2249    }
2250
2251    // Regression: bare-name HashSet classification produced false getters when two
2252    // types shared a field name with different scalarness (kreuzcrawl `content`
2253    // collision between CrawlConfig.content: ContentConfig and MarkdownResult.content: String).
2254    #[test]
2255    fn render_php_with_getters_distinguishes_same_field_name_on_different_types() {
2256        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2257        // A.content is non-scalar.
2258        getters.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2259        // B.content is scalar — explicit empty set.
2260        getters.insert("B".to_string(), HashSet::new());
2261        // Both A and B declare a "content" field — needed so the per-type
2262        // classification is consulted (not fallback bare-name union).
2263        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2264        all_fields.insert("A".to_string(), ["content".to_string()].into_iter().collect());
2265        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2266        let map_a = PhpGetterMap {
2267            getters: getters.clone(),
2268            field_types: HashMap::new(),
2269            root_type: Some("A".to_string()),
2270            all_fields: all_fields.clone(),
2271        };
2272        let map_b = PhpGetterMap {
2273            getters,
2274            field_types: HashMap::new(),
2275            root_type: Some("B".to_string()),
2276            all_fields,
2277        };
2278        let r_a = FieldResolver::new_with_php_getters(
2279            &HashMap::new(),
2280            &HashSet::new(),
2281            &HashSet::new(),
2282            &HashSet::new(),
2283            &HashSet::new(),
2284            &HashMap::new(),
2285            map_a,
2286        );
2287        let r_b = FieldResolver::new_with_php_getters(
2288            &HashMap::new(),
2289            &HashSet::new(),
2290            &HashSet::new(),
2291            &HashSet::new(),
2292            &HashSet::new(),
2293            &HashMap::new(),
2294            map_b,
2295        );
2296        assert_eq!(r_a.accessor("content", "php", "$a"), "$a->getContent()");
2297        assert_eq!(r_b.accessor("content", "php", "$b"), "$b->content");
2298    }
2299
2300    // Regression: the chain renderer must advance current_type through the IR's
2301    // nested-type graph so a scalar field on a nested type is not falsely
2302    // classified as needing a getter because some other type uses the same name.
2303    #[test]
2304    fn render_php_with_getters_chains_through_correct_type() {
2305        let mut fields = HashMap::new();
2306        fields.insert("nested_content".to_string(), "inner.content".to_string());
2307        let mut getters: HashMap<String, HashSet<String>> = HashMap::new();
2308        // Outer.inner is non-scalar (struct B).
2309        getters.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2310        // B.content is scalar.
2311        getters.insert("B".to_string(), HashSet::new());
2312        // Decoy: another type with non-scalar `content` field — used to verify
2313        // the legacy bare-name union would have produced the wrong answer.
2314        getters.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2315        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2316        field_types.insert(
2317            "Outer".to_string(),
2318            [("inner".to_string(), "B".to_string())].into_iter().collect(),
2319        );
2320        let mut all_fields: HashMap<String, HashSet<String>> = HashMap::new();
2321        all_fields.insert("Outer".to_string(), ["inner".to_string()].into_iter().collect());
2322        all_fields.insert("B".to_string(), ["content".to_string()].into_iter().collect());
2323        all_fields.insert("Decoy".to_string(), ["content".to_string()].into_iter().collect());
2324        let map = PhpGetterMap {
2325            getters,
2326            field_types,
2327            root_type: Some("Outer".to_string()),
2328            all_fields,
2329        };
2330        let r = FieldResolver::new_with_php_getters(
2331            &fields,
2332            &HashSet::new(),
2333            &HashSet::new(),
2334            &HashSet::new(),
2335            &HashSet::new(),
2336            &HashMap::new(),
2337            map,
2338        );
2339        assert_eq!(
2340            r.accessor("nested_content", "php", "$result"),
2341            "$result->getInner()->content"
2342        );
2343    }
2344}