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