Skip to main content

polydat_grammar/comprehension/
parse.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Comprehension spec parser — text → AST.
5//!
6//! The textual form is `var in expr` per clause, comma-separated
7//! at clause boundaries, with paren-respecting splitting so
8//! function-call argument commas and multi-value list commas
9//! aren't mistaken for clause separators.
10//!
11//! ## Two entry points
12//!
13//! - [`parse_clause_list`] turns one comma-separated string into
14//!   `Vec<Clause>`. Each entry from the YAML's array form, or the
15//!   top-level entries of the YAML's string form, calls this.
16//! - [`comprehension_from_subspaces`] takes the parsed sub-spaces
17//!   (each sub-space is a `Vec<Clause>` — a Cartesian list) and
18//!   decides between `ComprehensionMode::Cartesian` and
19//!   `ComprehensionMode::Union`. This is the structural
20//!   detection rule: any variable name repeating across the
21//!   sub-spaces' flat clause set ⇒ Union; otherwise Cartesian
22//!   over the flattened list.
23//!
24//! YAML-shape detection (string vs list vs object) stays in
25//! the host — it's YAML-shaped, not GK-shaped. The
26//! workload parser builds `Vec<Vec<Clause>>` from the YAML
27//! using these primitives, then calls
28//! [`comprehension_from_subspaces`].
29
30use std::collections::HashMap;
31
32use super::ast_legacy::{Clause, Comprehension, ShellOrigin, TraversalOrder, ZipMode};
33
34/// Parse a single clause.
35///
36/// Two shapes are recognised:
37///
38/// - **Single-var** (Layers 1–6): `var in expr`. The lone
39///   variable on the LHS binds successive values from the
40///   single source on the RHS. This is the historical shape
41///   and remains the common case.
42/// - **Parallel-iter** (SRD-18c Layer 7a): `(a, b, …) in
43///   (e1, e2, …)`. Each variable on the LHS binds the
44///   corresponding source on the RHS; the sources advance in
45///   lockstep ("zip"). Length-mismatch across the group is a
46///   strict-mode error at scope-init.
47///
48/// Returns `Err` for malformed input — the caller decides
49/// whether to keep going with whatever did parse cleanly. The
50/// error message names the clause text so it's surfaceable as
51/// a diagnostic.
52pub fn parse_clause(s: &str) -> Result<Clause, String> {
53    // Find the first top-paren-depth-0 ` in ` separator. The
54    // LHS may be a parenthesised group `(a, b)` whose internal
55    // commas are at depth ≥ 1; the RHS likewise. Walking with
56    // depth-aware lookahead is the only reliable split.
57    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
90/// Build a `Clause` from already-split `lhs` and `rhs` text.
91///
92/// LHS shape decides single-var vs parallel-iter:
93/// - `var` (bare identifier) → single-var clause.
94/// - `(a, b, ...)` → parallel-iter clause.
95///
96/// For parallel-iter, RHS is one of three forms (see
97/// [`ZipMode`]):
98/// - `(e1, e2, ...)` — strict zip (default).
99/// - `zip_truncate(e1, e2, ...)` — truncate to shortest.
100/// - `zip_cycle(e1, e2, ...)` — cycle to longest.
101///
102/// `whole` is only used to enrich error messages.
103fn 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        // For zip_truncate(...) / zip_cycle(...), `rhs_inner`
120        // is the parenthesised arg list and parses identically
121        // to the bare `(e1, e2)` form.
122        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
149/// Strip a leading `zip_truncate(...)` / `zip_cycle(...)`
150/// wrapper from `rhs` and return `(inner, mode)` where `inner`
151/// is the parenthesised argument list (still wrapped in
152/// parens) and `mode` is the corresponding [`ZipMode`].
153/// Returns `None` if `rhs` isn't a recognised zip-mode form.
154fn 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
169/// True if `s` starts with `(` and the matching close-paren is
170/// the final character (no trailing text). Whitespace inside is
171/// fine; whitespace outside is the caller's job to trim.
172fn 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
193/// Split a `(a, b, c)` group on its top-paren-depth-1 commas.
194/// Caller must ensure the input passes [`is_paren_wrapped`].
195fn 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
239/// Parse the full Polydat comprehension text grammar:
240/// `<clause_list> [where <predicate>]`.
241///
242/// The clause list is a comma-separated sequence of `var in expr`
243/// clauses (paren-respecting; see [`parse_clause_list`]). The
244/// optional `where` keyword at top-paren-depth-0 ends the
245/// clause list and starts a single Polydat predicate expression that
246/// runs to end-of-string. The predicate is **not** parsed here —
247/// it's stored as text and evaluated at iteration time against
248/// the per-tuple kernel.
249///
250/// Mode (Cartesian vs Union) is decided by
251/// [`comprehension_from_subspaces`] from the clause list. The
252/// filter, if present, attaches uniformly to both modes — one
253/// predicate per emitted tuple.
254///
255/// Examples:
256/// ```text
257/// k in 10,100, limit in 10,20,30
258/// k in 10,100 where k > 5
259/// k in 10,100, limit in 10,20,30,50,100,200,300 where k * limit < 1000
260/// ```
261pub fn parse_comprehension_text(text: &str) -> Result<Comprehension, String> {
262    // Split off the optional `order <spec>` first (it's the
263    // outermost clause), then split off the optional
264    // `where <pred>` from what remains.
265    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    // String form: each clause is its own sub-space so the
269    // detection rule (repeated names ⇒ Union) sees per-clause
270    // boundaries. Same convention `nbrs-workload` uses.
271    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    // Single-source invariant check: structural shape +
280    // index-space-ordering vs. Union compatibility.
281    comp.validate().map_err(|errs| errs.join("; "))?;
282    Ok(comp)
283}
284
285/// Backward-compat shim — call [`Comprehension::validate`]
286/// instead. Kept so external host callers don't
287/// need a same-day update; will be retired once those move.
288#[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
296/// Parse an order spec string into a [`TraversalOrder`].
297///
298/// Three syntactic shapes (per SRD-18d §"GK text grammar"):
299///
300/// - **Bare name**: `lex`, `extrema`, `shells`, `sobol`, …
301///   No truncation; uses the strategy's defaults.
302/// - **Terse `name/N`**: `extrema/1`, `shells/2`, `halton/64`,
303///   `lex/100`. The `/N` suffix is the strategy's natural
304///   truncation parameter (count, strata, or depth).
305/// - **Keyword form `name(arg=val, …)`**: full parameter
306///   surface for strategies with multiple knobs.
307///   `shells(origin=center, depth=3)`,
308///   `lhs(count=20, seed=42)`,
309///   `space_filling(sobol, count=64)`.
310pub 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    // Check for keyword form: `name(arg=val, ...)`
317    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    // Terse form `name/N` or bare `name`.
329    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            // `space_filling(strategy, count=N, seed=N)` —
413            // strategy is the first positional arg.
414            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
442/// Parse a keyword/positional argument body — `arg, key=val, key2=val2`.
443/// Positional args are returned with an empty key. Splits on commas
444/// at top paren-depth (function-call commas inside arg values are
445/// preserved). Strips matching surrounding quotes from values.
446fn 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
505/// Split a comprehension text on the top-level ` order `
506/// keyword. Returns `(head, Some(order_spec))` if an order
507/// clause is present at paren-depth 0, or `(text, None)`
508/// otherwise. The `order` keyword is consumed; the spec is
509/// trimmed.
510pub 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
543/// Split a comprehension text on the top-level ` where `
544/// keyword. Returns `(clause_text, Some(predicate))` if a
545/// `where` clause is present at paren-depth 0, or
546/// `(text, None)` otherwise. The predicate is trimmed; the
547/// `where` keyword itself is consumed.
548///
549/// "Top-level" means paren-depth 0 — `where` substrings inside
550/// `(...)`, `[...]`, or `{...}` are ignored, so a clause
551/// expression like `f(where_clause('foo'))` survives intact.
552pub 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
585/// Parse a comma-separated clause list — the textual content
586/// of one comprehension sub-space.
587///
588/// Splits on commas at paren-depth 0 that are followed (after
589/// whitespace) by an `<ident> in ` token. This splits real
590/// clause boundaries while leaving:
591///
592/// - Function-call inner commas:
593///   `matching_profiles('a', 'b')` stays one expression.
594/// - Multi-value inner commas:
595///   `limit in 10,20,30` stays one clause whose expression is
596///   `10,20,30`.
597///
598/// Each entry that doesn't parse as `var in expr` produces an
599/// error in the result; the function returns the first error
600/// encountered.
601pub 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
613/// Build a [`Comprehension`] from a list of pre-parsed
614/// sub-spaces. Each `subspaces[i]` is one Cartesian clause
615/// list (the output of [`parse_clause_list`] for one of the
616/// YAML's array-form entries, or one entry for the YAML's
617/// map / string forms).
618///
619/// **Detection rule**: if any variable name appears more than
620/// once across the flat list of all sub-spaces' clauses,
621/// emit `ComprehensionMode::Union` (preserving sub-space
622/// boundaries). Otherwise — every var name distinct — flatten
623/// into a single `ComprehensionMode::Cartesian` list.
624///
625/// This collapses the YAML's string form (which produces one
626/// sub-space per top-level clause) into the natural
627/// Cartesian shape when names are distinct, while still
628/// detecting repeats as a Union signal. Same rule the
629/// pre-refactor workload parser applied — see
630/// the host's scenario-node parser.
631pub 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
650/// Split a comma-separated clause list on clause boundaries.
651///
652/// A clause boundary is a comma at paren-depth 0 followed
653/// (after whitespace) by an `<ident> in ` token, where
654/// `<ident>` is a Rust-style identifier. The lookahead is the
655/// only reliable signal — the YAML grammar above doesn't
656/// supply any other syntactic boundary, so a comma might be a
657/// new clause OR a comma inside a value list / function call.
658///
659/// Returns the parts as owned `String`s so callers don't
660/// thread the input lifetime through every step. Empty parts
661/// (consecutive commas, leading/trailing whitespace) are
662/// preserved here and dropped in [`parse_clause_list`].
663pub 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                    // Comma is inside a value list — keep walking.
687                    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
702/// True if `tail` begins (after optional whitespace) with
703/// either:
704/// - an identifier followed by ` in ` (single-var clause), or
705/// - a `(<ident>, <ident>, ...)` group followed by ` in `
706///   (parallel-iter clause, SRD-18c Layer 7a).
707///
708/// Used by [`split_respecting_parens`] to recognise a clause
709/// boundary.
710fn is_clause_boundary(tail: &str) -> bool {
711    let trimmed = tail.trim_start();
712    if trimmed.starts_with('(') {
713        // Parallel-iter LHS: walk to matching close-paren.
714        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        // The inner `'{dataset}', '{prefix}'` commas live inside
770        // the matching_profiles() call — must not split.
771        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        // The `10,20,30` commas form a value list, not new
780        // clauses — there's no `<ident> in ` after them.
781        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        // The function-call inner comma is preserved; only one clause.
806        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        // Each clause in its own sub-space, distinct names
819        // ⇒ flatten into one Cartesian list. This is the
820        // string-form path: `"k in 10, limit in 20"` yields
821        // `[[(k, 10)], [(limit, 20)]]` from the parser, and
822        // here we collapse to `Cartesian([(k,10),(limit,20)])`.
823        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        // `k` appears in both sub-spaces ⇒ Union (preserves
836        // sub-space boundaries).
837        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        // Union dedups names for the operator-visible set.
844        assert_eq!(c.coordinate_names(), vec!["k", "limit"]);
845        // Flat clause count preserves repetition.
846        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        // `where` inside a function call shouldn't split.
866        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        // The string form `"k in 1, k in 2"` is one
1010        // top-level clause list with a repeated var name —
1011        // each clause becomes its own sub-space, then the
1012        // detection rule sees the repetition.
1013        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    // ── SRD-18e Push 10: Union + non-lex ordering rejection ──
1020
1021    #[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        // lex is a stable enumeration order with no
1050        // geometric reasoning — works fine on Union.
1051        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        // custom is the escape hatch — the user's function
1062        // decides what ordering means for their Union shape.
1063        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        // The rejection is Union-specific; Cartesian +
1071        // index-space orderings have always been valid.
1072        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        // Routes through Comprehension::validate (the canonical
1083        // invariant entry point) — verifies every named
1084        // index-space strategy is named in the error.
1085        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    // ---- Push 2: Layer 7a parallel-iter clauses --------------
1121
1122    #[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        // Nested parens inside the parallel-RHS group must not
1151        // confuse the splitter — the comma between fib(8) and
1152        // pow2(8) is at depth 1.
1153        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        // `(x) in (1..10)` is malformed parallel-iter (≥ 2 vars
1178        // required); use the single-var form instead.
1179        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        // A parallel-iter clause followed by a single-var clause
1186        // — the boundary detector must accept `(<ident>, ...) in `
1187        // as the start of a new clause.
1188        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    // ---- Round-trip: Display → parse → equal AST ------------
1210
1211    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}