Skip to main content

polydat_grammar/comprehension/spec/
source_parser.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Source-string grammar parser — turns the user-facing
5//! source expression (e.g. `"1..10"`, `"[a, b, c]"`,
6//! `"fib(8)"`) into a typed [`Source`] value.
7//!
8//! comprehension_forms.md §3.1 specifies the source forms, and
9//! the parsing lives here so every polydat consumer shares one
10//! source-grammar implementation.
11//!
12//! Recognized forms:
13//!
14//! | Source text | Produces |
15//! |---|---|
16//! | `1..10` | `IntRange { lo: 1, hi: 10, step: 1 }` |
17//! | `1..=10` | `IntRange { lo: 1, hi: 11, step: 1 }` (inclusive end) |
18//! | `1..10 step 2` | `IntRange { lo: 1, hi: 10, step: 2 }` |
19//! | `[a, b, c]` | `Literal { values: [Str, Str, Str] }` |
20//! | `[1, 2, 3]` | `Literal { values: [Int, Int, Int] }` |
21//! | `[1.0, 2.5]` | `Literal { values: [Float, Float] }` |
22//! | `[true, false]` | `Literal { values: [Bool, Bool] }` |
23//! | `{name}` | `WorkloadParamList { name: "name", len_hint: None }` |
24//! | `fib(8)` (or any `ident(...)`) | `Generator { expr, cardinality_hint: None }` |
25//! | `0.0..1.0` | `ContinuousInterval { interval, measure: Uniform }` |
26//! | `normal(0, 1)` | `Distribution { Normal, support: the measure's own, params }` |
27//! | `exponential(1) on 0.0..1.0` | `Distribution { Exponential, support: the interval, params }` |
28//!
29//! Any other text is a `Generator` expression the runtime
30//! evaluates; `SourceParseError::Unrecognized` is not produced by
31//! this path.
32
33use crate::comprehension::cardinality::{Interval, MeasureName, ProductMeasure};
34use crate::comprehension::source::{LiteralValue, Source};
35
36/// Parse a source-expression string into a typed [`Source`].
37pub fn parse_source(text: &str) -> Result<Source, SourceParseError> {
38    let trimmed = text.trim();
39
40    // Workload-param reference: `{name}` — accepts both the
41    // simple form (`{foo}`) and the dynamic form
42    // (`{a_{b}_c}`). Dynamic placeholders surface as the
43    // outer name with `_` separators; the runtime interpolator
44    // resolves the nesting before lookup.
45    if let Some(name) = strip_curly(trimmed) {
46        return Ok(Source::WorkloadParamList {
47            name,
48            len_hint: None,
49        });
50    }
51    if let Some(dyn_text) = strip_dynamic_curly(trimmed) {
52        return Ok(Source::WorkloadParamList {
53            name: dyn_text,
54            len_hint: None,
55        });
56    }
57
58    // String comprehension (comprehension_forms.md §3.1.3): a wholly-quoted string in
59    // source position. Quote-kind selects the iteration interior:
60    //   - double `"…"` → iterable: token-strip (comma/semicolon/
61    //     whitespace; colons etc. retained) into a literal list.
62    //   - single `'…'` → atomic: one whole-string element.
63    // (Outside the source slot a quoted token is a plain string
64    // literal; this branch only runs because we're parsing a
65    // comprehension source.)
66    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
67        let inner = &trimmed[1..trimmed.len() - 1];
68        let values = super::super::source::split_string_comprehension(inner)
69            .into_iter()
70            .map(parse_literal_value)
71            .collect();
72        return Ok(Source::Literal { values });
73    }
74    if trimmed.len() >= 2 && trimmed.starts_with('\'') && trimmed.ends_with('\'') {
75        let inner = &trimmed[1..trimmed.len() - 1];
76        return Ok(Source::Literal {
77            values: vec![LiteralValue::String(inner.to_string())],
78        });
79    }
80
81    // List comprehension sugar `[…]` (comprehension_forms.md §3.1.3, §3.1.4).
82    //   - Pure-literal list (numbers / bools / quoted strings,
83    //     no spread, no bare references) → `Source::Literal`,
84    //     baked at parse time with a static cardinality (the
85    //     historical fast path, unchanged).
86    //   - Otherwise — any bare-identifier *reference* element or
87    //     a `…`/`...` spread — defers to `Source::Generator`
88    //     carrying the bracket text verbatim, so the runtime
89    //     evaluator (`eval::try_eval_bracket_list`) resolves each
90    //     element against the kernel and applies spread peeling.
91    if trimmed.starts_with('[') && trimmed.ends_with(']') {
92        let inner = &trimmed[1..trimmed.len() - 1];
93        if bracket_is_pure_literal(inner) {
94            return parse_literal_list(inner);
95        }
96        return Ok(Source::Generator {
97            expr: trimmed.to_string(),
98            cardinality_hint: None,
99        });
100    }
101
102    // A named continuous measure (comprehension_forms.md §3.1,
103    // §10.7.5): `normal(0, 1)`, and
104    // `normal(0, 1) on 0.0..1.0` for the measure restricted to an
105    // interval. Before the range and call branches, which would read
106    // the interval alone or take the call for a generator.
107    if let Some(result) = parse_distribution(trimmed) {
108        return result;
109    }
110
111    // Range: contains `..` and starts with a number-ish.
112    if let Some(idx) = find_top_level(trimmed, "..") {
113        return parse_range(trimmed, idx);
114    }
115
116    // Function-call shape: `ident(...)` → Generator
117    if looks_like_function_call(trimmed) {
118        return Ok(Source::Generator {
119            expr: trimmed.to_string(),
120            cardinality_hint: None,
121        });
122    }
123
124    // Bare scalar literal: `10`, `"hello"`, `true`, `3.14` →
125    // single-element Literal: `k in 10` dispenses exactly one
126    // tuple.
127    if let Some(value) = try_parse_bare_scalar(trimmed) {
128        return Ok(Source::Literal {
129            values: vec![value],
130        });
131    }
132
133    // Bare comma-separated list: `k in 1,2,3` and `y in a,b,c`
134    // without brackets are a Literal list (the bare label list of
135    // comprehension_forms.md §3.1.4). The check is conservative:
136    // require a top-level comma and that no element contains
137    // syntax that would suggest a more complex expression
138    // (parens, brackets, braces, operators).
139    if trimmed.contains(',') && looks_like_bare_value_list(trimmed) {
140        return parse_literal_list(trimmed);
141    }
142
143    // Fallback: a Generator expression. Any other expression text
144    // (e.g. `pre_{outer}`, `mod_in(cycle, p)`, `range(0, {n})`) is
145    // resolved by the runtime evaluator via the Polydat Kernel
146    // chain; its cardinality is unknown here
147    // (cardinality_hint=None), and it renders back verbatim.
148    Ok(Source::Generator {
149        expr: trimmed.to_string(),
150        cardinality_hint: None,
151    })
152}
153
154/// A named continuous measure written as a source: `normal(0, 1)`
155/// draws from the measure's own support, and `normal(0, 1) on
156/// 0.0..1.0` restricts it to the interval. `None` when the text
157/// names no measure, so the caller reads it as something else.
158///
159/// The arguments are the measure's parameters in the order
160/// [`MeasureName::parameter_names`] gives; none means the standard
161/// ones. A name from the closed set with arguments that are not its
162/// own is an error, not a fall-through: `normal(1)` is a mistake to
163/// report, never a generator call.
164fn parse_distribution(text: &str) -> Option<Result<Source, SourceParseError>> {
165    let (call, support_text) = match split_on_keyword(text, " on ") {
166        Some((call, rest)) => (call, Some(rest)),
167        None => (text, None),
168    };
169    let (name, args) = split_call(call)?;
170    let measure = MeasureName::from_text(name)?;
171    Some(build_distribution(text, measure, args, support_text))
172}
173
174/// The measure source `text` denotes, or the error its arguments or
175/// its interval earn.
176fn build_distribution(
177    text: &str,
178    measure: MeasureName,
179    args: &str,
180    support_text: Option<&str>,
181) -> Result<Source, SourceParseError> {
182    let invalid = || SourceParseError::InvalidRange(text.to_string());
183    let mut params = Vec::new();
184    for arg in args.split(',') {
185        let arg = arg.trim();
186        if arg.is_empty() {
187            if params.is_empty() && args.trim().is_empty() {
188                break;
189            }
190            return Err(invalid());
191        }
192        params.push(arg.parse::<f64>().map_err(|_| invalid())?);
193    }
194    let params = measure.resolve_params(&params).map_err(|_| invalid())?;
195    let support = match support_text {
196        None => measure.support(&params),
197        Some(interval_text) => match parse_range_text(interval_text)? {
198            Source::ContinuousInterval { interval, .. } => interval,
199            _ => return Err(invalid()),
200        },
201    };
202    Ok(Source::Distribution {
203        distribution: measure,
204        support,
205        params,
206    })
207}
208
209/// `name(args)` split into its head and its argument text, or `None`
210/// when the text is not one call and nothing else.
211fn split_call(text: &str) -> Option<(&str, &str)> {
212    let text = text.trim();
213    let open = text.find('(')?;
214    let inner = text.strip_suffix(')')?;
215    let name = text[..open].trim();
216    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
217        return None;
218    }
219    Some((name, &inner[open + 1..]))
220}
221
222/// Split `text` at `keyword` outside any bracket or quote, the way a
223/// measure's interval is written after its call.
224fn split_on_keyword<'a>(text: &'a str, keyword: &str) -> Option<(&'a str, &'a str)> {
225    let bytes = text.as_bytes();
226    let mut depth = 0i32;
227    let mut quote: Option<u8> = None;
228    for i in 0..bytes.len() {
229        let c = bytes[i];
230        match quote {
231            Some(q) => {
232                if c == q {
233                    quote = None;
234                }
235            }
236            None => match c {
237                b'"' | b'\'' => quote = Some(c),
238                b'(' | b'[' | b'{' => depth += 1,
239                b')' | b']' | b'}' => depth -= 1,
240                _ => {
241                    if depth == 0 && text[i..].starts_with(keyword) {
242                        return Some((&text[..i], &text[i + keyword.len()..]));
243                    }
244                }
245            },
246        }
247    }
248    None
249}
250
251/// Conservative bare-comma-list detector. The form
252/// `k in 1,2,3` (no brackets) is a literal list; this matches
253/// it without misclassifying expression-like text. Same shape
254/// as `looks_like_literal_list` in
255/// `polydat::iteration::comprehension::eval`.
256fn looks_like_bare_value_list(text: &str) -> bool {
257    !text.chars().any(|c| {
258        matches!(
259            c,
260            '(' | ')'
261                | '['
262                | ']'
263                | '{'
264                | '}'
265                | '\''
266                | '"'
267                | '+'
268                | '*'
269                | '/'
270                | '%'
271                | '='
272                | '<'
273                | '>'
274                | '!'
275                | '&'
276                | '|'
277                | '~'
278                | '^'
279                | '?'
280        )
281    })
282}
283
284/// Detect dynamic-placeholder text like `{a_{b}_c}` (nested
285/// braces). Returns the contained text as the name; the runtime
286/// interpolator handles the nesting at lookup time.
287fn strip_dynamic_curly(s: &str) -> Option<String> {
288    let s = s.trim();
289    if !s.starts_with('{') || !s.ends_with('}') {
290        return None;
291    }
292    let inner = &s[1..s.len() - 1];
293    // Must contain at least one nested `{` — distinguishes
294    // dynamic from the simple `{name}` form `strip_curly`
295    // already handled.
296    if !inner.contains('{') {
297        return None;
298    }
299    Some(inner.to_string())
300}
301
302/// Parse a comma-separated literal list. Determines element
303/// type from the first element; mixed-type lists currently
304/// fall back to string.
305fn parse_literal_list(inner: &str) -> Result<Source, SourceParseError> {
306    let parts: Vec<&str> = inner
307        .split(',')
308        .map(|s| s.trim())
309        .filter(|s| !s.is_empty())
310        .collect();
311
312    if parts.is_empty() {
313        return Ok(Source::Literal { values: Vec::new() });
314    }
315
316    let values: Vec<LiteralValue> = parts.iter().map(|s| parse_literal_value(s)).collect();
317
318    Ok(Source::Literal { values })
319}
320
321/// True when every element of a bracket list is a pure literal
322/// (integer, float, bool, or quoted string) and there is no
323/// spread (`…`/`...`). Such lists bake to `Source::Literal` at
324/// parse time. A bare-identifier element (a reference) or a
325/// spread makes the list eval-time (`Source::Generator`)
326/// (comprehension_forms.md §3.1.4).
327fn bracket_is_pure_literal(inner: &str) -> bool {
328    let elems: Vec<&str> = inner
329        .split(',')
330        .map(str::trim)
331        .filter(|s| !s.is_empty())
332        .collect();
333    if elems.is_empty() {
334        return true; // `[]` is a (degenerate) literal list
335    }
336    elems.iter().all(|e| {
337        if e.ends_with('…') || e.ends_with("...") {
338            return false; // spread → eval-time
339        }
340        e.eq_ignore_ascii_case("true")
341            || e.eq_ignore_ascii_case("false")
342            || ((e.starts_with('"') && e.ends_with('"'))
343                || (e.starts_with('\'') && e.ends_with('\'')))
344            || e.parse::<i64>().is_ok()
345            || e.parse::<f64>().is_ok()
346    })
347}
348
349fn parse_literal_value(s: &str) -> LiteralValue {
350    let s = s.trim();
351    if s.eq_ignore_ascii_case("true") {
352        return LiteralValue::Bool(true);
353    }
354    if s.eq_ignore_ascii_case("false") {
355        return LiteralValue::Bool(false);
356    }
357    // Quoted string
358    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
359        let inner = &s[1..s.len() - 1];
360        return LiteralValue::String(inner.to_string());
361    }
362    // Integer
363    if let Ok(n) = s.parse::<i64>() {
364        return LiteralValue::Int(n);
365    }
366    if let Ok(n) = s.parse::<u64>() {
367        return LiteralValue::UInt(n);
368    }
369    // Float
370    if let Ok(f) = s.parse::<f64>() {
371        return LiteralValue::Float(f);
372    }
373    // Bare identifier → string literal
374    LiteralValue::String(s.to_string())
375}
376
377/// Parse a range expression starting at `dotdot_idx` (the
378/// position of `..`).
379fn parse_range_text(text: &str) -> Result<Source, SourceParseError> {
380    let text = text.trim();
381    match find_top_level(text, "..") {
382        Some(idx) => parse_range(text, idx),
383        None => Err(SourceParseError::InvalidRange(text.to_string())),
384    }
385}
386
387fn parse_range(text: &str, dotdot_idx: usize) -> Result<Source, SourceParseError> {
388    let lo_str = text[..dotdot_idx].trim();
389    let after = &text[dotdot_idx + 2..];
390
391    // `..=` inclusive form
392    let (inclusive_end, after) = if let Some(rest) = after.strip_prefix('=') {
393        (true, rest)
394    } else {
395        (false, after)
396    };
397
398    // Optional ` step N` suffix or the three-segment form
399    // `..N` (e.g. `1..10..2`, `1..=10..2`). Both are step
400    // suffixes. Check ` step ` first since it's the documented
401    // form.
402    let (rhs, step) = if let Some(step_pos) = after.find(" step ") {
403        let rhs = after[..step_pos].trim();
404        let step_str = after[step_pos + 6..].trim();
405        let step: i64 = step_str
406            .parse()
407            .map_err(|_| SourceParseError::InvalidRange(text.to_string()))?;
408        (rhs, step)
409    } else if let Some(step_pos) = after.find("..") {
410        // The `lo..hi..step` shape: the second `..` is
411        // the step separator.
412        let rhs = after[..step_pos].trim();
413        let step_str = after[step_pos + 2..].trim();
414        let step: i64 = step_str
415            .parse()
416            .map_err(|_| SourceParseError::InvalidRange(text.to_string()))?;
417        (rhs, step)
418    } else {
419        (after.trim(), 1)
420    };
421
422    // Try parsing both endpoints as integers first.
423    if let (Ok(lo_i), Ok(hi_i)) = (lo_str.parse::<i64>(), rhs.parse::<i64>()) {
424        let hi = if inclusive_end { hi_i + 1 } else { hi_i };
425        return Ok(Source::IntRange { lo: lo_i, hi, step });
426    }
427    // Otherwise try as floats → continuous interval.
428    if let (Ok(lo_f), Ok(hi_f)) = (lo_str.parse::<f64>(), rhs.parse::<f64>()) {
429        let interval = Interval {
430            lo: lo_f,
431            hi: hi_f,
432            lo_open: false,
433            hi_open: !inclusive_end,
434        };
435        return Ok(Source::ContinuousInterval {
436            interval,
437            measure: ProductMeasure::Uniform,
438        });
439    }
440
441    Err(SourceParseError::InvalidRange(text.to_string()))
442}
443
444fn strip_curly(s: &str) -> Option<String> {
445    let s = s.trim();
446    if s.starts_with('{') && s.ends_with('}') {
447        let inner = &s[1..s.len() - 1];
448        let trimmed = inner.trim();
449        if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') {
450            return Some(trimmed.to_string());
451        }
452    }
453    None
454}
455
456/// Try to parse `s` as a bare scalar literal — int, float,
457/// bool, or quoted string. Returns `None` if `s` is not a
458/// well-formed scalar (e.g., a bare identifier without
459/// quotes); bare identifiers ambiguously could be names rather
460/// than string literals, so we don't accept them here.
461fn try_parse_bare_scalar(s: &str) -> Option<LiteralValue> {
462    if s.eq_ignore_ascii_case("true") {
463        return Some(LiteralValue::Bool(true));
464    }
465    if s.eq_ignore_ascii_case("false") {
466        return Some(LiteralValue::Bool(false));
467    }
468    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
469        let inner = &s[1..s.len() - 1];
470        return Some(LiteralValue::String(inner.to_string()));
471    }
472    if let Ok(n) = s.parse::<i64>() {
473        return Some(LiteralValue::Int(n));
474    }
475    if let Ok(n) = s.parse::<u64>() {
476        return Some(LiteralValue::UInt(n));
477    }
478    if let Ok(f) = s.parse::<f64>() {
479        return Some(LiteralValue::Float(f));
480    }
481    None
482}
483
484fn looks_like_function_call(s: &str) -> bool {
485    let Some(open) = s.find('(') else {
486        return false;
487    };
488    if !s.ends_with(')') {
489        return false;
490    }
491    let name = &s[..open];
492    !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
493}
494
495/// Find the first top-level occurrence of `needle`,
496/// respecting `(`, `[`, `{` nesting.
497fn find_top_level(s: &str, needle: &str) -> Option<usize> {
498    let bytes = s.as_bytes();
499    let needle_bytes = needle.as_bytes();
500    let mut depth = 0i64;
501    let mut i = 0;
502    while i + needle_bytes.len() <= bytes.len() {
503        match bytes[i] {
504            b'(' | b'[' | b'{' => depth += 1,
505            b')' | b']' | b'}' => depth -= 1,
506            _ => {}
507        }
508        if depth == 0 && &bytes[i..i + needle_bytes.len()] == needle_bytes {
509            return Some(i);
510        }
511        i += 1;
512    }
513    None
514}
515
516/// Errors that can arise during source-string parsing.
517#[derive(Debug, Clone, PartialEq)]
518pub enum SourceParseError {
519    /// The source text doesn't match any recognized shape.
520    Unrecognized(String),
521    /// A range expression couldn't be parsed (bad endpoint
522    /// types, malformed step suffix, etc.).
523    InvalidRange(String),
524}
525
526impl std::fmt::Display for SourceParseError {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            SourceParseError::Unrecognized(s) => {
530                write!(f, "unrecognized source expression: {s:?}")
531            }
532            SourceParseError::InvalidRange(s) => {
533                write!(f, "invalid range expression: {s:?}")
534            }
535        }
536    }
537}
538
539impl std::error::Error for SourceParseError {}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn int_range_exclusive() {
547        let s = parse_source("1..10").unwrap();
548        assert!(matches!(
549            s,
550            Source::IntRange {
551                lo: 1,
552                hi: 10,
553                step: 1
554            }
555        ));
556    }
557
558    #[test]
559    fn double_quoted_source_is_string_comprehension_striped() {
560        // comprehension_forms.md §3.1.3: double-quoted source → token-strip.
561        let s = parse_source(r#""rerank_def, rerank_1x, rerank_2x""#).unwrap();
562        match s {
563            Source::Literal { values } => {
564                assert_eq!(
565                    values,
566                    vec![
567                        LiteralValue::String("rerank_def".into()),
568                        LiteralValue::String("rerank_1x".into()),
569                        LiteralValue::String("rerank_2x".into()),
570                    ]
571                );
572            }
573            other => panic!("expected striped Literal, got {other:?}"),
574        }
575    }
576
577    #[test]
578    fn single_quoted_source_is_atomic() {
579        // comprehension_forms.md §3.1.3: single-quoted source → one whole element.
580        let s = parse_source("'rerank_def, rerank_1x'").unwrap();
581        match s {
582            Source::Literal { values } => {
583                assert_eq!(
584                    values,
585                    vec![LiteralValue::String("rerank_def, rerank_1x".into())]
586                );
587            }
588            other => panic!("expected atomic Literal, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn int_range_inclusive() {
594        let s = parse_source("1..=10").unwrap();
595        assert!(matches!(
596            s,
597            Source::IntRange {
598                lo: 1,
599                hi: 11,
600                step: 1
601            }
602        ));
603    }
604
605    #[test]
606    fn int_range_with_step() {
607        let s = parse_source("0..100 step 10").unwrap();
608        assert!(matches!(
609            s,
610            Source::IntRange {
611                lo: 0,
612                hi: 100,
613                step: 10
614            }
615        ));
616    }
617
618    #[test]
619    fn literal_int_list() {
620        let s = parse_source("[1, 2, 3]").unwrap();
621        match s {
622            Source::Literal { values } => {
623                assert_eq!(values.len(), 3);
624                assert_eq!(values[0], LiteralValue::Int(1));
625                assert_eq!(values[2], LiteralValue::Int(3));
626            }
627            other => panic!("expected Literal, got {other:?}"),
628        }
629    }
630
631    #[test]
632    fn bracket_bare_words_are_references_not_strings() {
633        // comprehension_forms.md §3.1.4: bare-word bracket elements are wire
634        // *references*, not string literals — so the list defers
635        // to a Generator (resolved at eval time) rather than
636        // baking `["a","b","c"]`. To get string literals, quote
637        // them (see `literal_quoted_strings`).
638        let s = parse_source("[a, b, c]").unwrap();
639        match s {
640            Source::Generator { expr, .. } => assert_eq!(expr, "[a, b, c]"),
641            other => panic!("expected deferred Generator, got {other:?}"),
642        }
643    }
644
645    #[test]
646    fn bracket_with_spread_defers_to_generator() {
647        let s = parse_source("[xs…]").unwrap();
648        assert!(
649            matches!(s, Source::Generator { .. }),
650            "spread list must defer: {s:?}"
651        );
652    }
653
654    #[test]
655    fn literal_quoted_strings() {
656        let s = parse_source(r#"["hello", "world"]"#).unwrap();
657        match s {
658            Source::Literal { values } => {
659                assert_eq!(values[0], LiteralValue::String("hello".into()));
660                assert_eq!(values[1], LiteralValue::String("world".into()));
661            }
662            other => panic!("expected Literal, got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn literal_float_list() {
668        let s = parse_source("[1.5, 2.5, 3.5]").unwrap();
669        match s {
670            Source::Literal { values } => {
671                assert_eq!(values[0], LiteralValue::Float(1.5));
672            }
673            other => panic!("expected Literal, got {other:?}"),
674        }
675    }
676
677    #[test]
678    fn workload_param_ref() {
679        let s = parse_source("{profiles}").unwrap();
680        match s {
681            Source::WorkloadParamList { name, .. } => assert_eq!(name, "profiles"),
682            other => panic!("expected WorkloadParamList, got {other:?}"),
683        }
684    }
685
686    #[test]
687    fn generator_function_call() {
688        let s = parse_source("fib(8)").unwrap();
689        match s {
690            Source::Generator { expr, .. } => assert_eq!(expr, "fib(8)"),
691            other => panic!("expected Generator, got {other:?}"),
692        }
693    }
694
695    #[test]
696    fn continuous_interval_via_floats() {
697        let s = parse_source("0.0..1.0").unwrap();
698        match s {
699            Source::ContinuousInterval { interval, measure } => {
700                assert_eq!(interval.lo, 0.0);
701                assert_eq!(interval.hi, 1.0);
702                assert!(matches!(measure, ProductMeasure::Uniform));
703            }
704            other => panic!("expected ContinuousInterval, got {other:?}"),
705        }
706    }
707
708    #[test]
709    fn continuous_interval_inclusive() {
710        let s = parse_source("0.0..=1.0").unwrap();
711        match s {
712            Source::ContinuousInterval { interval, .. } => {
713                assert!(!interval.hi_open);
714            }
715            other => panic!("expected ContinuousInterval, got {other:?}"),
716        }
717    }
718
719    #[test]
720    fn unrecognized_source_falls_back_to_generator() {
721        // The source grammar accepts arbitrary expression text and
722        // the runtime evaluator resolves it via the Polydat Kernel
723        // chain, so an unrecognized shape passes through as a
724        // Generator expression rather than failing the parse.
725        let s = parse_source("totally nonsense").unwrap();
726        match s {
727            Source::Generator { expr, .. } => assert_eq!(expr, "totally nonsense"),
728            other => panic!("expected Generator, got {other:?}"),
729        }
730    }
731
732    #[test]
733    fn empty_literal_list() {
734        let s = parse_source("[]").unwrap();
735        match s {
736            Source::Literal { values } => assert!(values.is_empty()),
737            other => panic!("expected empty Literal, got {other:?}"),
738        }
739    }
740
741    /// A named measure is a source of its own: the call alone draws
742    /// from the measure's support, and `on <interval>` restricts it.
743    #[test]
744    fn a_named_measure_parses_as_a_distribution() {
745        match parse_source("normal(0, 1)").unwrap() {
746            Source::Distribution {
747                distribution: MeasureName::Normal,
748                support,
749                params,
750            } => {
751                assert_eq!(params, vec![0.0, 1.0]);
752                assert!(support.lo.is_infinite() && support.hi.is_infinite());
753            }
754            other => panic!("expected a normal distribution, got {other:?}"),
755        }
756        match parse_source("exponential(2) on 0.0..1.0").unwrap() {
757            Source::Distribution {
758                distribution: MeasureName::Exponential,
759                support,
760                params,
761            } => {
762                assert_eq!(params, vec![2.0]);
763                assert_eq!(support, Interval::half_open(0.0, 1.0));
764            }
765            other => panic!("expected a restricted exponential, got {other:?}"),
766        }
767        // No arguments takes the measure's standard parameters, and a
768        // Pareto's support starts at its scale.
769        match parse_source("pareto(3, 2)").unwrap() {
770            Source::Distribution {
771                support, params, ..
772            } => {
773                assert_eq!(params, vec![3.0, 2.0]);
774                assert_eq!(support.lo, 3.0);
775            }
776            other => panic!("{other:?}"),
777        }
778        assert!(matches!(
779            parse_source("uniform01()").unwrap(),
780            Source::Distribution {
781                distribution: MeasureName::Uniform01,
782                ..
783            }
784        ));
785    }
786
787    /// Only the closed set of measures is a distribution; any other
788    /// call is the generator it always was.
789    #[test]
790    fn a_call_that_names_no_measure_is_still_a_generator() {
791        for text in ["fib(8)", "partitions(\"*/4\", 100)", "range(0, 10)"] {
792            assert!(
793                matches!(parse_source(text).unwrap(), Source::Generator { .. }),
794                "{text}"
795            );
796        }
797        // An interval alone keeps its uniform measure.
798        assert!(matches!(
799            parse_source("0.0..1.0").unwrap(),
800            Source::ContinuousInterval { .. }
801        ));
802    }
803
804    /// A measure's arguments are its own: a wrong count is the error
805    /// the measure's parameter table names, not a generator call.
806    #[test]
807    fn a_measure_with_the_wrong_arguments_is_an_error() {
808        for text in ["normal(1)", "normal(0, 1, 2)", "beta(a, b)", "gamma(1,)"] {
809            assert!(parse_source(text).is_err(), "{text}");
810        }
811    }
812}