1use std::collections::HashMap;
31
32use super::ast_legacy::{Clause, Comprehension, ShellOrigin, TraversalOrder, ZipMode};
33
34pub fn parse_clause(s: &str) -> Result<Clause, String> {
53 let bytes = s.as_bytes();
58 let mut depth: i32 = 0;
59 let mut i: usize = 0;
60 while i + 4 <= bytes.len() {
61 let ch = bytes[i];
62 match ch {
63 b'(' | b'[' | b'{' => {
64 depth += 1;
65 i += 1;
66 }
67 b')' | b']' | b'}' => {
68 depth -= 1;
69 i += 1;
70 }
71 b' ' if depth == 0
72 && bytes.get(i + 1) == Some(&b'i')
73 && bytes.get(i + 2) == Some(&b'n')
74 && bytes.get(i + 3) == Some(&b' ') =>
75 {
76 let lhs = s[..i].trim();
77 let rhs = s[i + 4..].trim();
78 return parse_clause_from_sides(lhs, rhs, s);
79 }
80 _ => {
81 i += 1;
82 }
83 }
84 }
85 Err(format!(
86 "invalid for_each clause: '{s}' (expected 'var in expr')"
87 ))
88}
89
90fn parse_clause_from_sides(lhs: &str, rhs: &str, whole: &str) -> Result<Clause, String> {
104 let lhs_paren = is_paren_wrapped(lhs);
105 let (rhs_inner, mode) = match strip_zip_mode_prefix(rhs) {
106 Some((inner, m)) => (inner, m),
107 None => (rhs.to_string(), ZipMode::Strict),
108 };
109 let rhs_paren = is_paren_wrapped(&rhs_inner);
110 let rhs_explicit_paren = mode != ZipMode::Strict || rhs_paren;
111 if lhs_paren || rhs_explicit_paren {
112 if !(lhs_paren && rhs_explicit_paren) {
113 return Err(format!(
114 "parallel-iter clause '{whole}' requires parentheses on both sides \
115 (e.g. '(a, b) in (e1, e2)' or '(a, b) in zip_truncate(e1, e2)')"
116 ));
117 }
118 let vars = split_paren_group(lhs);
119 let exprs = split_paren_group(&rhs_inner);
123 for v in &vars {
124 if !is_simple_ident(v) {
125 return Err(format!(
126 "parallel-iter clause '{whole}': '{v}' is not a valid variable name"
127 ));
128 }
129 }
130 if vars.len() < 2 {
131 return Err(format!(
132 "parallel-iter clause '{whole}' requires ≥ 2 variables \
133 (use 'var in expr' for the single-var form)"
134 ));
135 }
136 if vars.len() != exprs.len() {
137 return Err(format!(
138 "parallel-iter clause '{whole}': {} variables but {} expressions",
139 vars.len(),
140 exprs.len()
141 ));
142 }
143 Ok(Clause::parallel_with_mode(mode, vars, exprs))
144 } else {
145 Ok(Clause::new(lhs, rhs))
146 }
147}
148
149fn strip_zip_mode_prefix(rhs: &str) -> Option<(String, ZipMode)> {
155 for (prefix, mode) in [
156 ("zip_truncate", ZipMode::Truncate),
157 ("zip_cycle", ZipMode::Cycle),
158 ] {
159 if let Some(rest) = rhs.strip_prefix(prefix) {
160 let trimmed = rest.trim_start();
161 if trimmed.starts_with('(') && is_paren_wrapped(trimmed) {
162 return Some((trimmed.to_string(), mode));
163 }
164 }
165 }
166 None
167}
168
169fn is_paren_wrapped(s: &str) -> bool {
173 let bytes = s.as_bytes();
174 if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
175 return false;
176 }
177 let mut depth: i32 = 0;
178 for (i, &b) in bytes.iter().enumerate() {
179 match b {
180 b'(' => depth += 1,
181 b')' => {
182 depth -= 1;
183 if depth == 0 {
184 return i == bytes.len() - 1;
185 }
186 }
187 _ => {}
188 }
189 }
190 false
191}
192
193fn split_paren_group(s: &str) -> Vec<String> {
196 let inner = &s[1..s.len() - 1];
197 let bytes = inner.as_bytes();
198 let mut parts: Vec<String> = Vec::new();
199 let mut start: usize = 0;
200 let mut depth: i32 = 0;
201 let mut i: usize = 0;
202 while i < bytes.len() {
203 let ch = bytes[i];
204 match ch {
205 b'(' | b'[' | b'{' => {
206 depth += 1;
207 i += 1;
208 }
209 b')' | b']' | b'}' => {
210 depth -= 1;
211 i += 1;
212 }
213 b',' if depth == 0 => {
214 parts.push(inner[start..i].trim().to_string());
215 start = i + 1;
216 i += 1;
217 }
218 _ => {
219 i += 1;
220 }
221 }
222 }
223 let tail = inner[start..].trim();
224 if !tail.is_empty() {
225 parts.push(tail.to_string());
226 }
227 parts
228}
229
230fn is_simple_ident(s: &str) -> bool {
231 let mut chars = s.chars();
232 match chars.next() {
233 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
234 _ => return false,
235 }
236 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
237}
238
239pub fn parse_comprehension_text(text: &str) -> Result<Comprehension, String> {
262 let (head, order_text) = split_at_order(text);
266 let (clause_text, filter) = split_at_where(&head);
267 let clauses = parse_clause_list(&clause_text)?;
268 let subspaces: Vec<Vec<Clause>> = clauses.into_iter().map(|c| vec![c]).collect();
272 let mut comp = comprehension_from_subspaces(subspaces);
273 if let Some(predicate) = filter {
274 comp = comp.with_filter(predicate);
275 }
276 if let Some(spec) = order_text {
277 comp = comp.with_order(parse_order_spec(&spec)?);
278 }
279 comp.validate().map_err(|errs| errs.join("; "))?;
282 Ok(comp)
283}
284
285#[deprecated(note = "use Comprehension::validate() — single source of truth for AST invariants")]
289pub fn validate_order_for_mode(
290 mode: &super::ast_legacy::ComprehensionMode,
291 order: &Option<TraversalOrder>,
292) -> Result<(), String> {
293 super::ast_legacy::check_order_for_mode(mode, order)
294}
295
296pub fn parse_order_spec(text: &str) -> Result<TraversalOrder, String> {
311 let trimmed = text.trim();
312 if trimmed.is_empty() {
313 return Err("order spec is empty".to_string());
314 }
315
316 if let Some(open) = trimmed.find('(') {
318 if !trimmed.ends_with(')') {
319 return Err(format!(
320 "order spec '{trimmed}': unbalanced parens — expected `name(...)`"
321 ));
322 }
323 let name = trimmed[..open].trim();
324 let body = &trimmed[open + 1..trimmed.len() - 1];
325 return build_order_from_keyword(name, body);
326 }
327
328 let (name, n_opt) = match trimmed.find('/') {
330 Some(slash) => {
331 let n_text = trimmed[slash + 1..].trim();
332 let n = n_text.parse::<usize>().map_err(|_| format!(
333 "order spec '{trimmed}': '/N' suffix must be a non-negative integer, got '{n_text}'"
334 ))?;
335 (trimmed[..slash].trim(), Some(n))
336 }
337 None => (trimmed, None),
338 };
339
340 build_order_from_terse(name, n_opt)
341}
342
343fn build_order_from_terse(name: &str, n: Option<usize>) -> Result<TraversalOrder, String> {
344 match name {
345 "lex" => Ok(TraversalOrder::Lex { count: n }),
346 "reverse_lex" => Ok(TraversalOrder::ReverseLex { count: n }),
347 "diagonal" => Ok(TraversalOrder::Diagonal { count: n }),
348 "antidiagonal" => Ok(TraversalOrder::Antidiagonal { count: n }),
349 "extrema" => Ok(TraversalOrder::Extrema { strata: n }),
350 "shells" => Ok(TraversalOrder::Shells {
351 origin: ShellOrigin::Outer,
352 depth: n,
353 }),
354 "halton" => Ok(TraversalOrder::Halton { count: n }),
355 "sobol" => Ok(TraversalOrder::Sobol { count: n }),
356 "lhs" => Ok(TraversalOrder::Lhs {
357 count: n,
358 seed: None,
359 }),
360 "custom" => Err(
361 "order spec 'custom': use 'custom(<function>)' to name the Polydat function"
362 .to_string(),
363 ),
364 other => Err(format!(
365 "order spec: unknown strategy '{other}' — \
366 expected one of lex/reverse_lex/diagonal/antidiagonal/extrema/shells/halton/sobol/lhs/custom"
367 )),
368 }
369}
370
371fn build_order_from_keyword(name: &str, body: &str) -> Result<TraversalOrder, String> {
372 let args = parse_keyword_args(body)?;
373 let count = args
374 .iter()
375 .find_map(|(k, v)| (k == "count").then(|| v.parse::<usize>().ok()).flatten());
376 let depth = args
377 .iter()
378 .find_map(|(k, v)| (k == "depth").then(|| v.parse::<usize>().ok()).flatten());
379 let strata = args
380 .iter()
381 .find_map(|(k, v)| (k == "strata").then(|| v.parse::<usize>().ok()).flatten());
382 let seed = args
383 .iter()
384 .find_map(|(k, v)| (k == "seed").then(|| v.parse::<u64>().ok()).flatten());
385
386 match name {
387 "lex" => Ok(TraversalOrder::Lex { count }),
388 "reverse_lex" => Ok(TraversalOrder::ReverseLex { count }),
389 "diagonal" => Ok(TraversalOrder::Diagonal { count }),
390 "antidiagonal" => Ok(TraversalOrder::Antidiagonal { count }),
391 "extrema" => Ok(TraversalOrder::Extrema { strata }),
392 "shells" => {
393 let origin = match args
394 .iter()
395 .find_map(|(k, v)| (k == "origin").then_some(v.as_str()))
396 {
397 Some("outer") | None => ShellOrigin::Outer,
398 Some("center") => ShellOrigin::Center,
399 Some("corner") => ShellOrigin::Corner,
400 Some(other) => {
401 return Err(format!(
402 "order shells: unknown origin '{other}' — expected outer/center/corner"
403 ));
404 }
405 };
406 Ok(TraversalOrder::Shells { origin, depth })
407 }
408 "halton" => Ok(TraversalOrder::Halton { count }),
409 "sobol" => Ok(TraversalOrder::Sobol { count }),
410 "lhs" => Ok(TraversalOrder::Lhs { count, seed }),
411 "space_filling" => {
412 let strategy = args
415 .iter()
416 .find(|(k, _)| k.is_empty())
417 .map(|(_, v)| v.as_str())
418 .ok_or_else(|| {
419 "space_filling: missing strategy name (halton/sobol/lhs)".to_string()
420 })?;
421 match strategy {
422 "halton" => Ok(TraversalOrder::Halton { count }),
423 "sobol" => Ok(TraversalOrder::Sobol { count }),
424 "lhs" => Ok(TraversalOrder::Lhs { count, seed }),
425 other => Err(format!(
426 "space_filling: unknown strategy '{other}' — expected halton/sobol/lhs"
427 )),
428 }
429 }
430 "custom" => {
431 let function = args
432 .iter()
433 .find(|(k, _)| k.is_empty())
434 .map(|(_, v)| v.clone())
435 .ok_or_else(|| "custom: missing function name".to_string())?;
436 Ok(TraversalOrder::Custom { function })
437 }
438 other => Err(format!("order spec: unknown strategy '{other}'")),
439 }
440}
441
442fn parse_keyword_args(body: &str) -> Result<Vec<(String, String)>, String> {
447 let mut out = Vec::new();
448 let bytes = body.as_bytes();
449 let n = bytes.len();
450 let mut start = 0;
451 let mut i = 0;
452 let mut depth: u32 = 0;
453 let push = |s: &str, out: &mut Vec<(String, String)>| {
454 let trimmed = s.trim();
455 if trimmed.is_empty() {
456 return;
457 }
458 let (k, v) = if let Some(eq) = trimmed.find('=') {
459 (
460 trimmed[..eq].trim().to_string(),
461 trimmed[eq + 1..].trim().to_string(),
462 )
463 } else {
464 (String::new(), trimmed.to_string())
465 };
466 let v = strip_quotes(&v);
467 out.push((k, v));
468 };
469 while i < n {
470 let ch = bytes[i];
471 match ch {
472 b'(' | b'[' | b'{' => {
473 depth = depth.saturating_add(1);
474 i += 1;
475 }
476 b')' | b']' | b'}' => {
477 depth = depth.saturating_sub(1);
478 i += 1;
479 }
480 b',' if depth == 0 => {
481 push(&body[start..i], &mut out);
482 start = i + 1;
483 i += 1;
484 }
485 _ => {
486 i += 1;
487 }
488 }
489 }
490 push(&body[start..], &mut out);
491 Ok(out)
492}
493
494fn strip_quotes(s: &str) -> String {
495 let s = s.trim();
496 if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
497 || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
498 {
499 s[1..s.len() - 1].to_string()
500 } else {
501 s.to_string()
502 }
503}
504
505pub fn split_at_order(text: &str) -> (String, Option<String>) {
511 const KEYWORD: &str = " order ";
512 let bytes = text.as_bytes();
513 let n = bytes.len();
514 let mut depth: u32 = 0;
515 let mut i: usize = 0;
516 while i < n {
517 let ch = bytes[i];
518 match ch {
519 b'(' | b'[' | b'{' => {
520 depth = depth.saturating_add(1);
521 i += 1;
522 }
523 b')' | b']' | b'}' => {
524 depth = depth.saturating_sub(1);
525 i += 1;
526 }
527 b' ' if depth == 0 && text.is_char_boundary(i) && text[i..].starts_with(KEYWORD) => {
528 let head = text[..i].to_string();
529 let spec = text[i + KEYWORD.len()..].trim().to_string();
530 if spec.is_empty() {
531 return (text.to_string(), None);
532 }
533 return (head, Some(spec));
534 }
535 _ => {
536 i += 1;
537 }
538 }
539 }
540 (text.to_string(), None)
541}
542
543pub fn split_at_where(text: &str) -> (String, Option<String>) {
553 const KEYWORD: &str = " where ";
554 let bytes = text.as_bytes();
555 let n = bytes.len();
556 let mut depth: u32 = 0;
557 let mut i: usize = 0;
558 while i < n {
559 let ch = bytes[i];
560 match ch {
561 b'(' | b'[' | b'{' => {
562 depth = depth.saturating_add(1);
563 i += 1;
564 }
565 b')' | b']' | b'}' => {
566 depth = depth.saturating_sub(1);
567 i += 1;
568 }
569 b' ' if depth == 0 && text.is_char_boundary(i) && text[i..].starts_with(KEYWORD) => {
570 let prefix = text[..i].to_string();
571 let suffix = text[i + KEYWORD.len()..].trim().to_string();
572 if suffix.is_empty() {
573 return (text.to_string(), None);
574 }
575 return (prefix, Some(suffix));
576 }
577 _ => {
578 i += 1;
579 }
580 }
581 }
582 (text.to_string(), None)
583}
584
585pub fn parse_clause_list(text: &str) -> Result<Vec<Clause>, String> {
602 let mut out = Vec::new();
603 for part in split_respecting_parens(text) {
604 let trimmed = part.trim();
605 if trimmed.is_empty() {
606 continue;
607 }
608 out.push(parse_clause(trimmed)?);
609 }
610 Ok(out)
611}
612
613pub fn comprehension_from_subspaces(subspaces: Vec<Vec<Clause>>) -> Comprehension {
632 let mut counts: HashMap<&str, usize> = HashMap::new();
633 for set in &subspaces {
634 for clause in set {
635 for v in &clause.vars {
636 *counts.entry(v.as_str()).or_insert(0) += 1;
637 }
638 }
639 }
640 let any_repeat = counts.values().any(|c| *c > 1);
641
642 if any_repeat {
643 Comprehension::union(subspaces)
644 } else {
645 let flat: Vec<Clause> = subspaces.into_iter().flatten().collect();
646 Comprehension::cartesian(flat)
647 }
648}
649
650pub fn split_respecting_parens(s: &str) -> Vec<String> {
664 let bytes = s.as_bytes();
665 let mut parts: Vec<String> = Vec::new();
666 let mut start: usize = 0;
667 let mut i: usize = 0;
668 let mut depth: u32 = 0;
669 while i < bytes.len() {
670 let ch = bytes[i];
671 match ch {
672 b'(' | b'[' | b'{' => {
673 depth = depth.saturating_add(1);
674 i += 1;
675 }
676 b')' | b']' | b'}' => {
677 depth = depth.saturating_sub(1);
678 i += 1;
679 }
680 b',' if depth == 0 => {
681 if is_clause_boundary(&s[i + 1..]) {
682 parts.push(s[start..i].to_string());
683 start = i + 1;
684 i += 1;
685 } else {
686 i += 1;
688 }
689 }
690 _ => {
691 i += 1;
692 }
693 }
694 }
695 let tail = &s[start..];
696 if !tail.trim().is_empty() {
697 parts.push(tail.to_string());
698 }
699 parts
700}
701
702fn is_clause_boundary(tail: &str) -> bool {
711 let trimmed = tail.trim_start();
712 if trimmed.starts_with('(') {
713 let bytes = trimmed.as_bytes();
715 let mut depth: i32 = 0;
716 for (i, &b) in bytes.iter().enumerate() {
717 match b {
718 b'(' => depth += 1,
719 b')' => {
720 depth -= 1;
721 if depth == 0 {
722 let after = &trimmed[i + 1..];
723 return after.starts_with(" in ");
724 }
725 }
726 _ => {}
727 }
728 }
729 return false;
730 }
731 let mut ident_end = 0;
732 for (i, c) in trimmed.char_indices() {
733 if i == 0 {
734 if !(c.is_ascii_alphabetic() || c == '_') {
735 return false;
736 }
737 } else if !(c.is_ascii_alphanumeric() || c == '_') {
738 ident_end = i;
739 break;
740 }
741 ident_end = i + c.len_utf8();
742 }
743 if ident_end == 0 {
744 return false;
745 }
746 let after = &trimmed[ident_end..];
747 after.starts_with(" in ")
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 #[test]
755 fn parse_single_clause() {
756 let c = parse_clause("k in {k_values}").unwrap();
757 assert_eq!(c.var(), "k");
758 assert_eq!(c.expr(), "{k_values}");
759 }
760
761 #[test]
762 fn parse_clause_rejects_malformed() {
763 let err = parse_clause("not a clause").unwrap_err();
764 assert!(err.contains("invalid for_each clause"));
765 }
766
767 #[test]
768 fn split_respects_parens_in_function_call() {
769 let s = "profile in matching_profiles('{dataset}', '{prefix}')";
772 let parts = split_respecting_parens(s);
773 assert_eq!(parts.len(), 1);
774 assert_eq!(parts[0], s);
775 }
776
777 #[test]
778 fn split_respects_inner_value_list_commas() {
779 let s = "k in 10, limit in 10,20,30";
782 let parts = split_respecting_parens(s);
783 assert_eq!(parts.len(), 2);
784 assert_eq!(parts[0], "k in 10");
785 assert_eq!(parts[1].trim(), "limit in 10,20,30");
786 }
787
788 #[test]
789 fn split_handles_multiple_real_boundaries() {
790 let s = "a in 1, b in 2, c in 3";
791 let parts = split_respecting_parens(s);
792 assert_eq!(parts.len(), 3);
793 }
794
795 #[test]
796 fn parse_clause_list_distinct_names() {
797 let clauses = parse_clause_list("k in {k_values}, limit in {k_{k}_limits}").unwrap();
798 assert_eq!(clauses.len(), 2);
799 assert_eq!(clauses[0].var(), "k");
800 assert_eq!(clauses[1].var(), "limit");
801 }
802
803 #[test]
804 fn parse_clause_list_paren_safe() {
805 let clauses =
807 parse_clause_list("profile in matching_profiles('{dataset}', '{prefix}')").unwrap();
808 assert_eq!(clauses.len(), 1);
809 assert_eq!(clauses[0].var(), "profile");
810 assert_eq!(
811 clauses[0].expr(),
812 "matching_profiles('{dataset}', '{prefix}')"
813 );
814 }
815
816 #[test]
817 fn comprehension_from_subspaces_distinct_names_flattens_to_cartesian() {
818 let subspaces = vec![
824 vec![Clause::new("k", "10")],
825 vec![Clause::new("limit", "20")],
826 ];
827 let c = comprehension_from_subspaces(subspaces);
828 assert!(c.is_cartesian());
829 assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
830 assert_eq!(c.flat_clauses().len(), 2);
831 }
832
833 #[test]
834 fn comprehension_from_subspaces_repeated_names_yields_union() {
835 let subspaces = vec![
838 vec![Clause::new("k", "10"), Clause::new("limit", "10,20,30")],
839 vec![Clause::new("k", "100"), Clause::new("limit", "100,200,300")],
840 ];
841 let c = comprehension_from_subspaces(subspaces);
842 assert!(c.is_union());
843 assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
845 assert_eq!(c.flat_clauses().len(), 4);
847 }
848
849 #[test]
850 fn split_at_where_simple() {
851 let (clauses, filter) = split_at_where("k in 10,100 where k > 5");
852 assert_eq!(clauses, "k in 10,100");
853 assert_eq!(filter, Some("k > 5".to_string()));
854 }
855
856 #[test]
857 fn split_at_where_no_predicate() {
858 let (clauses, filter) = split_at_where("k in 10,100, limit in 10,20,30");
859 assert_eq!(clauses, "k in 10,100, limit in 10,20,30");
860 assert_eq!(filter, None);
861 }
862
863 #[test]
864 fn split_at_where_inside_parens_is_ignored() {
865 let (clauses, filter) = split_at_where("p in pick(profiles, where='ann') where p == 'x'");
867 assert_eq!(clauses, "p in pick(profiles, where='ann')");
868 assert_eq!(filter, Some("p == 'x'".to_string()));
869 }
870
871 #[test]
872 fn parse_comprehension_text_no_filter() {
873 let comp = parse_comprehension_text("k in 10,100, limit in 10,20,30").unwrap();
874 assert!(comp.is_cartesian());
875 assert_eq!(comp.coordinate_names(), vec!["k", "limit"]);
876 assert_eq!(comp.filter, None);
877 }
878
879 #[test]
880 fn parse_comprehension_text_with_filter() {
881 let comp =
882 parse_comprehension_text("k in 10,100, limit in 10,20,30 where k * limit < 1000")
883 .unwrap();
884 assert!(comp.is_cartesian());
885 assert_eq!(comp.coordinate_names(), vec!["k", "limit"]);
886 assert_eq!(comp.filter, Some("k * limit < 1000".to_string()));
887 }
888
889 #[test]
890 fn parse_comprehension_text_repeated_var_yields_union_with_filter() {
891 let comp = parse_comprehension_text("k in 1, k in 2 where k > 0").unwrap();
892 assert!(comp.is_union());
893 assert_eq!(comp.filter, Some("k > 0".to_string()));
894 }
895
896 #[test]
897 fn split_at_order_simple() {
898 let (head, order) = split_at_order("k in 1..10 order extrema/1");
899 assert_eq!(head, "k in 1..10");
900 assert_eq!(order, Some("extrema/1".to_string()));
901 }
902
903 #[test]
904 fn split_at_order_no_order() {
905 let (head, order) = split_at_order("k in 1..10 where {k} > 5");
906 assert_eq!(head, "k in 1..10 where {k} > 5");
907 assert_eq!(order, None);
908 }
909
910 #[test]
911 fn split_at_order_inside_parens_is_ignored() {
912 let (head, order) = split_at_order("p in pick(profiles, order='ann') order lex");
913 assert_eq!(head, "p in pick(profiles, order='ann')");
914 assert_eq!(order, Some("lex".to_string()));
915 }
916
917 #[test]
918 fn parse_order_spec_bare() {
919 match parse_order_spec("lex").unwrap() {
920 TraversalOrder::Lex { count: None } => {}
921 other => panic!("expected Lex, got {other:?}"),
922 }
923 match parse_order_spec("extrema").unwrap() {
924 TraversalOrder::Extrema { strata: None } => {}
925 other => panic!("expected Extrema, got {other:?}"),
926 }
927 match parse_order_spec("shells").unwrap() {
928 TraversalOrder::Shells {
929 origin: ShellOrigin::Outer,
930 depth: None,
931 } => {}
932 other => panic!("expected Shells outer/None, got {other:?}"),
933 }
934 }
935
936 #[test]
937 fn parse_order_spec_terse() {
938 match parse_order_spec("extrema/1").unwrap() {
939 TraversalOrder::Extrema { strata: Some(1) } => {}
940 other => panic!("expected Extrema strata=1, got {other:?}"),
941 }
942 match parse_order_spec("shells/2").unwrap() {
943 TraversalOrder::Shells {
944 origin: ShellOrigin::Outer,
945 depth: Some(2),
946 } => {}
947 other => panic!("expected Shells outer/2, got {other:?}"),
948 }
949 match parse_order_spec("halton/64").unwrap() {
950 TraversalOrder::Halton { count: Some(64) } => {}
951 other => panic!("expected Halton count=64, got {other:?}"),
952 }
953 match parse_order_spec("lex/100").unwrap() {
954 TraversalOrder::Lex { count: Some(100) } => {}
955 other => panic!("expected Lex count=100, got {other:?}"),
956 }
957 }
958
959 #[test]
960 fn parse_order_spec_keyword() {
961 match parse_order_spec("shells(origin=center, depth=3)").unwrap() {
962 TraversalOrder::Shells {
963 origin: ShellOrigin::Center,
964 depth: Some(3),
965 } => {}
966 other => panic!("expected Shells center/3, got {other:?}"),
967 }
968 match parse_order_spec("lhs(count=20, seed=42)").unwrap() {
969 TraversalOrder::Lhs {
970 count: Some(20),
971 seed: Some(42),
972 } => {}
973 other => panic!("expected Lhs count=20 seed=42, got {other:?}"),
974 }
975 match parse_order_spec("space_filling(sobol, count=64)").unwrap() {
976 TraversalOrder::Sobol { count: Some(64) } => {}
977 other => panic!("expected Sobol count=64, got {other:?}"),
978 }
979 }
980
981 #[test]
982 fn parse_order_spec_unknown_strategy_errors() {
983 let err = parse_order_spec("zigzag").unwrap_err();
984 assert!(err.contains("unknown strategy"), "got: {err}");
985 }
986
987 #[test]
988 fn parse_comprehension_text_with_order() {
989 let comp = parse_comprehension_text("k in 1..10 where {k} > 3 order extrema/1").unwrap();
990 assert_eq!(comp.filter, Some("{k} > 3".to_string()));
991 match comp.order {
992 Some(TraversalOrder::Extrema { strata: Some(1) }) => {}
993 other => panic!("expected Extrema strata=1, got {other:?}"),
994 }
995 }
996
997 #[test]
998 fn parse_comprehension_text_order_only() {
999 let comp = parse_comprehension_text("k in 1..10, l in 1..10 order halton/50").unwrap();
1000 assert_eq!(comp.filter, None);
1001 assert!(matches!(
1002 comp.order,
1003 Some(TraversalOrder::Halton { count: Some(50) })
1004 ));
1005 }
1006
1007 #[test]
1008 fn comprehension_from_subspaces_string_form_repeated_var_yields_union() {
1009 let subspaces = vec![vec![Clause::new("k", "1")], vec![Clause::new("k", "2")]];
1014 let c = comprehension_from_subspaces(subspaces);
1015 assert!(c.is_union());
1016 assert_eq!(c.coordinate_names(), vec!["k"]);
1017 }
1018
1019 #[test]
1022 fn union_plus_extrema_is_rejected() {
1023 let err = parse_comprehension_text("k in 10, k in 100 order extrema/1").unwrap_err();
1024 assert!(
1025 err.contains("'extrema'") && err.contains("Union"),
1026 "wrong message: {err}"
1027 );
1028 assert!(
1029 err.contains("Cartesian") || err.contains("lex"),
1030 "should hint at remedy: {err}"
1031 );
1032 }
1033
1034 #[test]
1035 fn union_plus_halton_is_rejected() {
1036 let err = parse_comprehension_text("k in 10, l in 100, k in 200, l in 400 order halton/64")
1037 .unwrap_err();
1038 assert!(err.contains("'halton'") && err.contains("Union"), "{err}");
1039 }
1040
1041 #[test]
1042 fn union_plus_shells_is_rejected() {
1043 let err = parse_comprehension_text("k in 10, k in 100 order shells/2").unwrap_err();
1044 assert!(err.contains("'shells'") && err.contains("Union"), "{err}");
1045 }
1046
1047 #[test]
1048 fn union_plus_lex_is_accepted() {
1049 let comp = parse_comprehension_text("k in 10, k in 100 order lex").unwrap();
1052 assert!(comp.is_union());
1053 assert!(matches!(
1054 comp.order,
1055 Some(TraversalOrder::Lex { count: None })
1056 ));
1057 }
1058
1059 #[test]
1060 fn union_plus_custom_is_accepted() {
1061 let comp = parse_comprehension_text("k in 10, k in 100 order custom(my_fn)").unwrap();
1064 assert!(comp.is_union());
1065 assert!(matches!(comp.order, Some(TraversalOrder::Custom { .. })));
1066 }
1067
1068 #[test]
1069 fn cartesian_plus_extrema_remains_valid() {
1070 let comp = parse_comprehension_text("k in 1..10, l in 1..10 order extrema/1").unwrap();
1073 assert!(comp.is_cartesian());
1074 assert!(matches!(
1075 comp.order,
1076 Some(TraversalOrder::Extrema { strata: Some(1) })
1077 ));
1078 }
1079
1080 #[test]
1081 fn validate_rejects_each_index_space_strategy_on_union() {
1082 for (label, ord) in [
1086 ("reverse_lex", TraversalOrder::ReverseLex { count: None }),
1087 ("diagonal", TraversalOrder::Diagonal { count: None }),
1088 ("antidiagonal", TraversalOrder::Antidiagonal { count: None }),
1089 ("extrema", TraversalOrder::Extrema { strata: None }),
1090 (
1091 "shells",
1092 TraversalOrder::Shells {
1093 origin: ShellOrigin::Outer,
1094 depth: None,
1095 },
1096 ),
1097 ("halton", TraversalOrder::Halton { count: None }),
1098 ("sobol", TraversalOrder::Sobol { count: None }),
1099 (
1100 "lhs",
1101 TraversalOrder::Lhs {
1102 count: None,
1103 seed: None,
1104 },
1105 ),
1106 ] {
1107 let comp = Comprehension::union(vec![
1108 vec![Clause::new("k", "10")],
1109 vec![Clause::new("k", "20")],
1110 ])
1111 .with_order(ord);
1112 let errs = comp.validate().unwrap_err();
1113 assert!(
1114 errs.iter().any(|e| e.contains(label)),
1115 "{label}: error should name the strategy: {errs:?}"
1116 );
1117 }
1118 }
1119
1120 #[test]
1123 fn parse_clause_parallel_two_vars() {
1124 let c = parse_clause("(x, y) in (1..10, 100..1000..100)").unwrap();
1125 assert!(c.is_parallel());
1126 assert_eq!(c.vars, vec!["x".to_string(), "y".to_string()]);
1127 match &c.source {
1128 super::super::ast_legacy::ClauseSource::Parallel { exprs, .. } => {
1129 assert_eq!(
1130 exprs,
1131 &vec!["1..10".to_string(), "100..1000..100".to_string()]
1132 );
1133 }
1134 _ => panic!("expected Parallel source"),
1135 }
1136 }
1137
1138 #[test]
1139 fn parse_clause_parallel_three_vars() {
1140 let c = parse_clause("(a, b, c) in (1..3, 10..30..10, 100..300..100)").unwrap();
1141 assert!(c.is_parallel());
1142 assert_eq!(
1143 c.vars,
1144 vec!["a".to_string(), "b".to_string(), "c".to_string()]
1145 );
1146 }
1147
1148 #[test]
1149 fn parse_clause_parallel_with_function_call_rhs() {
1150 let c = parse_clause("(x, y) in (fib(8), pow2(8))").unwrap();
1154 assert!(c.is_parallel());
1155 match &c.source {
1156 super::super::ast_legacy::ClauseSource::Parallel { exprs, .. } => {
1157 assert_eq!(exprs, &vec!["fib(8)".to_string(), "pow2(8)".to_string()]);
1158 }
1159 _ => panic!("expected Parallel source"),
1160 }
1161 }
1162
1163 #[test]
1164 fn parse_clause_paren_only_one_side_is_rejected() {
1165 let err = parse_clause("(x, y) in 1..10").unwrap_err();
1166 assert!(err.contains("parentheses on both sides"), "got: {err}");
1167 }
1168
1169 #[test]
1170 fn parse_clause_parallel_count_mismatch_is_rejected() {
1171 let err = parse_clause("(x, y, z) in (1..10, 1..20)").unwrap_err();
1172 assert!(err.contains("3 variables but 2 expressions"), "got: {err}");
1173 }
1174
1175 #[test]
1176 fn parse_clause_parallel_single_var_is_rejected() {
1177 let err = parse_clause("(x) in (1..10)").unwrap_err();
1180 assert!(err.contains("≥ 2 variables"), "got: {err}");
1181 }
1182
1183 #[test]
1184 fn split_respects_parallel_clause_boundaries() {
1185 let s = "(x, y) in (1..2, 10..20..10), z in 100..200..100";
1189 let parts = split_respecting_parens(s);
1190 assert_eq!(parts.len(), 2);
1191 }
1192
1193 #[test]
1194 fn parse_clause_list_mixed_parallel_and_single() {
1195 let clauses =
1196 parse_clause_list("(x, y) in (1..2, 10..20..10), z in 100..200..100").unwrap();
1197 assert_eq!(clauses.len(), 2);
1198 assert!(clauses[0].is_parallel());
1199 assert!(!clauses[1].is_parallel());
1200 assert_eq!(clauses[1].var(), "z");
1201 }
1202
1203 #[test]
1204 fn parse_clause_parallel_invalid_var_name_is_rejected() {
1205 let err = parse_clause("(x, 9b) in (1..10, 1..10)").unwrap_err();
1206 assert!(err.contains("not a valid variable name"), "got: {err}");
1207 }
1208
1209 fn roundtrip_clause(c: Clause) {
1212 let text = c.to_string();
1213 let reparsed =
1214 parse_clause(&text).unwrap_or_else(|e| panic!("re-parse failed for '{text}': {e}"));
1215 assert_eq!(
1216 c, reparsed,
1217 "round-trip diverged: original={c:?}\n text='{text}'\n reparsed={reparsed:?}"
1218 );
1219 }
1220
1221 #[test]
1222 fn round_trip_single_var_clause() {
1223 roundtrip_clause(Clause::new("k", "1..10"));
1224 roundtrip_clause(Clause::new("limit", "fib(8)"));
1225 }
1226
1227 #[test]
1228 fn round_trip_parallel_strict() {
1229 use super::super::ast_legacy::ZipMode;
1230 roundtrip_clause(Clause::parallel(["x", "y"], ["fib(8)", "pow2(8)"]));
1231 roundtrip_clause(Clause::parallel_with_mode(
1232 ZipMode::Strict,
1233 ["a", "b", "c"],
1234 ["1..3", "10..30..10", "100..300..100"],
1235 ));
1236 }
1237
1238 #[test]
1239 fn round_trip_parallel_truncate_and_cycle() {
1240 use super::super::ast_legacy::ZipMode;
1241 roundtrip_clause(Clause::parallel_with_mode(
1242 ZipMode::Truncate,
1243 ["x", "y"],
1244 ["fib(8)", "pow2(4)"],
1245 ));
1246 roundtrip_clause(Clause::parallel_with_mode(
1247 ZipMode::Cycle,
1248 ["x", "y"],
1249 ["fib(4)", "pow2(8)"],
1250 ));
1251 }
1252
1253 fn roundtrip_comprehension_text(text: &str) {
1254 let parsed = parse_comprehension_text(text)
1255 .unwrap_or_else(|e| panic!("parse failed for '{text}': {e}"));
1256 let rendered = parsed.to_string();
1257 let reparsed = parse_comprehension_text(&rendered)
1258 .unwrap_or_else(|e| panic!("re-parse failed for '{rendered}' (from '{text}'): {e}"));
1259 assert_eq!(
1260 parsed, reparsed,
1261 "round-trip diverged for '{text}':\n rendered='{rendered}'"
1262 );
1263 }
1264
1265 #[test]
1266 fn round_trip_cartesian_comprehension() {
1267 roundtrip_comprehension_text("k in 1..10");
1268 roundtrip_comprehension_text("k in 1..10, limit in fib(8)");
1269 roundtrip_comprehension_text("k in 1..10 where {k} > 3");
1270 roundtrip_comprehension_text("k in 1..10 order extrema/2");
1271 roundtrip_comprehension_text("k in 1..10, l in 1..10 where {k} != {l} order extrema/1");
1272 }
1273
1274 #[test]
1275 fn round_trip_parallel_iter_through_full_comprehension_text() {
1276 roundtrip_comprehension_text("(x, y) in (fib(5), pow2(5))");
1277 roundtrip_comprehension_text("(x, y) in zip_truncate(fib(8), pow2(4))");
1278 roundtrip_comprehension_text("(x, y) in (fib(4), pow2(4)), z in 1..3 order extrema/1");
1279 }
1280
1281 #[test]
1282 fn parse_clause_parallel_zip_truncate_mode() {
1283 use super::super::ast_legacy::{ClauseSource, ZipMode};
1284 let c = parse_clause("(x, y) in zip_truncate(1..10, fib(8))").unwrap();
1285 match &c.source {
1286 ClauseSource::Parallel { mode, exprs } => {
1287 assert_eq!(*mode, ZipMode::Truncate);
1288 assert_eq!(exprs, &vec!["1..10".to_string(), "fib(8)".to_string()]);
1289 }
1290 _ => panic!("expected Parallel source, got {:?}", c.source),
1291 }
1292 }
1293
1294 #[test]
1295 fn parse_clause_parallel_zip_cycle_mode() {
1296 use super::super::ast_legacy::{ClauseSource, ZipMode};
1297 let c = parse_clause("(x, y) in zip_cycle(1..10, 100..1000..100)").unwrap();
1298 match &c.source {
1299 ClauseSource::Parallel { mode, .. } => {
1300 assert_eq!(*mode, ZipMode::Cycle);
1301 }
1302 _ => panic!("expected Parallel source"),
1303 }
1304 }
1305
1306 #[test]
1307 fn parse_clause_parallel_default_mode_is_strict() {
1308 use super::super::ast_legacy::{ClauseSource, ZipMode};
1309 let c = parse_clause("(x, y) in (1..10, 100..1000..100)").unwrap();
1310 match &c.source {
1311 ClauseSource::Parallel { mode, .. } => {
1312 assert_eq!(*mode, ZipMode::Strict);
1313 }
1314 _ => panic!("expected Parallel source"),
1315 }
1316 }
1317}