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//! Polydat owns the source-string grammar per the audit
9//! resolution + this design pass: SRD-18c covers the parser-
10//! layer surface conceptually, but the actual parsing lives
11//! here so all polydat consumers share one canonical
12//! source-grammar implementation.
13//!
14//! Recognized forms:
15//!
16//! | Source text | Produces |
17//! |---|---|
18//! | `1..10` | `IntRange { lo: 1, hi: 10, step: 1 }` |
19//! | `1..=10` | `IntRange { lo: 1, hi: 11, step: 1 }` (inclusive end) |
20//! | `1..10 step 2` | `IntRange { lo: 1, hi: 10, step: 2 }` |
21//! | `[a, b, c]` | `Literal { values: [Str, Str, Str] }` |
22//! | `[1, 2, 3]` | `Literal { values: [Int, Int, Int] }` |
23//! | `[1.0, 2.5]` | `Literal { values: [Float, Float] }` |
24//! | `[true, false]` | `Literal { values: [Bool, Bool] }` |
25//! | `{name}` | `WorkloadParamList { name: "name", len_hint: None }` |
26//! | `fib(8)` (or any `ident(...)`) | `Generator { expr, cardinality_hint: None }` |
27//! | `0.0..1.0` | `ContinuousInterval { interval, measure: Uniform }` |
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, 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    // SRD-18f string comprehension: 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 `[…]` (SRD-18f Stage 2).
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    // Range: contains `..` and starts with a number-ish.
103    if let Some(idx) = find_top_level(trimmed, "..") {
104        return parse_range(trimmed, idx);
105    }
106
107    // Function-call shape: `ident(...)` → Generator
108    if looks_like_function_call(trimmed) {
109        return Ok(Source::Generator {
110            expr: trimmed.to_string(),
111            cardinality_hint: None,
112        });
113    }
114
115    // Bare scalar literal: `10`, `"hello"`, `true`, `3.14` →
116    // single-element Literal. This matches the legacy
117    // grammar's `k in 10` shape, where the RHS is a single
118    // literal value (the comprehension dispenses exactly one
119    // tuple).
120    if let Some(value) = try_parse_bare_scalar(trimmed) {
121        return Ok(Source::Literal {
122            values: vec![value],
123        });
124    }
125
126    // Bare comma-separated list — the legacy grammar accepts
127    // `k in 1,2,3` and `y in a,b,c` without brackets. Treat
128    // it as a Literal list. The check is conservative:
129    // require a top-level comma and that no element contains
130    // syntax that would suggest a more complex expression
131    // (parens, brackets, braces, operators).
132    if trimmed.contains(',') && looks_like_bare_value_list(trimmed) {
133        return parse_literal_list(trimmed);
134    }
135
136    // Fallback: treat as a Generator expression. The legacy
137    // grammar accepts arbitrary expression text (e.g.
138    // `pre_{outer}`, `mod_in(cycle, p)`, `range(0, {n})`)
139    // that the runtime evaluator resolves via the Polydat Kernel
140    // chain. The algebra-layer typing for these is generator
141    // (cardinality_hint=None); the bridge back to legacy
142    // round-trips them verbatim.
143    Ok(Source::Generator {
144        expr: trimmed.to_string(),
145        cardinality_hint: None,
146    })
147}
148
149/// Conservative bare-comma-list detector. The legacy form
150/// `k in 1,2,3` (no brackets) is a literal list; this matches
151/// it without misclassifying expression-like text. Same shape
152/// as the legacy `looks_like_literal_list` in
153/// `polydat::iteration::comprehension::eval`.
154fn looks_like_bare_value_list(text: &str) -> bool {
155    !text.chars().any(|c| {
156        matches!(
157            c,
158            '(' | ')'
159                | '['
160                | ']'
161                | '{'
162                | '}'
163                | '\''
164                | '"'
165                | '+'
166                | '*'
167                | '/'
168                | '%'
169                | '='
170                | '<'
171                | '>'
172                | '!'
173                | '&'
174                | '|'
175                | '~'
176                | '^'
177                | '?'
178        )
179    })
180}
181
182/// Detect dynamic-placeholder text like `{a_{b}_c}` (nested
183/// braces). Returns the contained text as the name; the runtime
184/// interpolator handles the nesting at lookup time.
185fn strip_dynamic_curly(s: &str) -> Option<String> {
186    let s = s.trim();
187    if !s.starts_with('{') || !s.ends_with('}') {
188        return None;
189    }
190    let inner = &s[1..s.len() - 1];
191    // Must contain at least one nested `{` — distinguishes
192    // dynamic from the simple `{name}` form `strip_curly`
193    // already handled.
194    if !inner.contains('{') {
195        return None;
196    }
197    Some(inner.to_string())
198}
199
200/// Parse a comma-separated literal list. Determines element
201/// type from the first element; mixed-type lists currently
202/// fall back to string.
203fn parse_literal_list(inner: &str) -> Result<Source, SourceParseError> {
204    let parts: Vec<&str> = inner
205        .split(',')
206        .map(|s| s.trim())
207        .filter(|s| !s.is_empty())
208        .collect();
209
210    if parts.is_empty() {
211        return Ok(Source::Literal { values: Vec::new() });
212    }
213
214    let values: Vec<LiteralValue> = parts.iter().map(|s| parse_literal_value(s)).collect();
215
216    Ok(Source::Literal { values })
217}
218
219/// True when every element of a bracket list is a pure literal
220/// (integer, float, bool, or quoted string) and there is no
221/// spread (`…`/`...`). Such lists bake to `Source::Literal` at
222/// parse time. A bare-identifier element (a reference) or a
223/// spread makes the list eval-time (`Source::Generator`).
224/// SRD-18f Stage 2.
225fn bracket_is_pure_literal(inner: &str) -> bool {
226    let elems: Vec<&str> = inner
227        .split(',')
228        .map(str::trim)
229        .filter(|s| !s.is_empty())
230        .collect();
231    if elems.is_empty() {
232        return true; // `[]` is a (degenerate) literal list
233    }
234    elems.iter().all(|e| {
235        if e.ends_with('…') || e.ends_with("...") {
236            return false; // spread → eval-time
237        }
238        e.eq_ignore_ascii_case("true")
239            || e.eq_ignore_ascii_case("false")
240            || ((e.starts_with('"') && e.ends_with('"'))
241                || (e.starts_with('\'') && e.ends_with('\'')))
242            || e.parse::<i64>().is_ok()
243            || e.parse::<f64>().is_ok()
244    })
245}
246
247fn parse_literal_value(s: &str) -> LiteralValue {
248    let s = s.trim();
249    if s.eq_ignore_ascii_case("true") {
250        return LiteralValue::Bool(true);
251    }
252    if s.eq_ignore_ascii_case("false") {
253        return LiteralValue::Bool(false);
254    }
255    // Quoted string
256    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
257        let inner = &s[1..s.len() - 1];
258        return LiteralValue::String(inner.to_string());
259    }
260    // Integer
261    if let Ok(n) = s.parse::<i64>() {
262        return LiteralValue::Int(n);
263    }
264    // Float
265    if let Ok(f) = s.parse::<f64>() {
266        return LiteralValue::Float(f);
267    }
268    // Bare identifier → string literal
269    LiteralValue::String(s.to_string())
270}
271
272/// Parse a range expression starting at `dotdot_idx` (the
273/// position of `..`).
274fn parse_range(text: &str, dotdot_idx: usize) -> Result<Source, SourceParseError> {
275    let lo_str = text[..dotdot_idx].trim();
276    let after = &text[dotdot_idx + 2..];
277
278    // `..=` inclusive form
279    let (inclusive_end, after) = if let Some(rest) = after.strip_prefix('=') {
280        (true, rest)
281    } else {
282        (false, after)
283    };
284
285    // Optional ` step N` suffix OR legacy three-segment form
286    // `..N` (e.g. `1..10..2`, `1..=10..2`). Both are step
287    // suffixes; the legacy form predates the keyword. Check
288    // ` step ` first since it's the documented form.
289    let (rhs, step) = if let Some(step_pos) = after.find(" step ") {
290        let rhs = after[..step_pos].trim();
291        let step_str = after[step_pos + 6..].trim();
292        let step: i64 = step_str
293            .parse()
294            .map_err(|_| SourceParseError::InvalidRange(text.to_string()))?;
295        (rhs, step)
296    } else if let Some(step_pos) = after.find("..") {
297        // Legacy `lo..hi..step` shape — the second `..` is
298        // the step separator.
299        let rhs = after[..step_pos].trim();
300        let step_str = after[step_pos + 2..].trim();
301        let step: i64 = step_str
302            .parse()
303            .map_err(|_| SourceParseError::InvalidRange(text.to_string()))?;
304        (rhs, step)
305    } else {
306        (after.trim(), 1)
307    };
308
309    // Try parsing both endpoints as integers first.
310    if let (Ok(lo_i), Ok(hi_i)) = (lo_str.parse::<i64>(), rhs.parse::<i64>()) {
311        let hi = if inclusive_end { hi_i + 1 } else { hi_i };
312        return Ok(Source::IntRange { lo: lo_i, hi, step });
313    }
314    // Otherwise try as floats → continuous interval.
315    if let (Ok(lo_f), Ok(hi_f)) = (lo_str.parse::<f64>(), rhs.parse::<f64>()) {
316        let interval = Interval {
317            lo: lo_f,
318            hi: hi_f,
319            lo_open: false,
320            hi_open: !inclusive_end,
321        };
322        return Ok(Source::ContinuousInterval {
323            interval,
324            measure: ProductMeasure::Uniform,
325        });
326    }
327
328    Err(SourceParseError::InvalidRange(text.to_string()))
329}
330
331fn strip_curly(s: &str) -> Option<String> {
332    let s = s.trim();
333    if s.starts_with('{') && s.ends_with('}') {
334        let inner = &s[1..s.len() - 1];
335        let trimmed = inner.trim();
336        if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') {
337            return Some(trimmed.to_string());
338        }
339    }
340    None
341}
342
343/// Try to parse `s` as a bare scalar literal — int, float,
344/// bool, or quoted string. Returns `None` if `s` is not a
345/// well-formed scalar (e.g., a bare identifier without
346/// quotes); bare identifiers ambiguously could be names rather
347/// than string literals, so we don't accept them here.
348fn try_parse_bare_scalar(s: &str) -> Option<LiteralValue> {
349    if s.eq_ignore_ascii_case("true") {
350        return Some(LiteralValue::Bool(true));
351    }
352    if s.eq_ignore_ascii_case("false") {
353        return Some(LiteralValue::Bool(false));
354    }
355    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
356        let inner = &s[1..s.len() - 1];
357        return Some(LiteralValue::String(inner.to_string()));
358    }
359    if let Ok(n) = s.parse::<i64>() {
360        return Some(LiteralValue::Int(n));
361    }
362    if let Ok(f) = s.parse::<f64>() {
363        return Some(LiteralValue::Float(f));
364    }
365    None
366}
367
368fn looks_like_function_call(s: &str) -> bool {
369    let Some(open) = s.find('(') else {
370        return false;
371    };
372    if !s.ends_with(')') {
373        return false;
374    }
375    let name = &s[..open];
376    !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
377}
378
379/// Find the first top-level occurrence of `needle`,
380/// respecting `(`, `[`, `{` nesting.
381fn find_top_level(s: &str, needle: &str) -> Option<usize> {
382    let bytes = s.as_bytes();
383    let needle_bytes = needle.as_bytes();
384    let mut depth = 0i64;
385    let mut i = 0;
386    while i + needle_bytes.len() <= bytes.len() {
387        match bytes[i] {
388            b'(' | b'[' | b'{' => depth += 1,
389            b')' | b']' | b'}' => depth -= 1,
390            _ => {}
391        }
392        if depth == 0 && &bytes[i..i + needle_bytes.len()] == needle_bytes {
393            return Some(i);
394        }
395        i += 1;
396    }
397    None
398}
399
400/// Errors that can arise during source-string parsing.
401#[derive(Debug, Clone, PartialEq)]
402pub enum SourceParseError {
403    /// The source text doesn't match any recognized shape.
404    Unrecognized(String),
405    /// A range expression couldn't be parsed (bad endpoint
406    /// types, malformed step suffix, etc.).
407    InvalidRange(String),
408}
409
410impl std::fmt::Display for SourceParseError {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        match self {
413            SourceParseError::Unrecognized(s) => {
414                write!(f, "unrecognized source expression: {s:?}")
415            }
416            SourceParseError::InvalidRange(s) => {
417                write!(f, "invalid range expression: {s:?}")
418            }
419        }
420    }
421}
422
423impl std::error::Error for SourceParseError {}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn int_range_exclusive() {
431        let s = parse_source("1..10").unwrap();
432        assert!(matches!(
433            s,
434            Source::IntRange {
435                lo: 1,
436                hi: 10,
437                step: 1
438            }
439        ));
440    }
441
442    #[test]
443    fn double_quoted_source_is_string_comprehension_striped() {
444        // SRD-18f §3.2: double-quoted source → token-strip.
445        let s = parse_source(r#""rerank_def, rerank_1x, rerank_2x""#).unwrap();
446        match s {
447            Source::Literal { values } => {
448                assert_eq!(
449                    values,
450                    vec![
451                        LiteralValue::String("rerank_def".into()),
452                        LiteralValue::String("rerank_1x".into()),
453                        LiteralValue::String("rerank_2x".into()),
454                    ]
455                );
456            }
457            other => panic!("expected striped Literal, got {other:?}"),
458        }
459    }
460
461    #[test]
462    fn single_quoted_source_is_atomic() {
463        // SRD-18f §3.2: single-quoted source → one whole element.
464        let s = parse_source("'rerank_def, rerank_1x'").unwrap();
465        match s {
466            Source::Literal { values } => {
467                assert_eq!(
468                    values,
469                    vec![LiteralValue::String("rerank_def, rerank_1x".into())]
470                );
471            }
472            other => panic!("expected atomic Literal, got {other:?}"),
473        }
474    }
475
476    #[test]
477    fn int_range_inclusive() {
478        let s = parse_source("1..=10").unwrap();
479        assert!(matches!(
480            s,
481            Source::IntRange {
482                lo: 1,
483                hi: 11,
484                step: 1
485            }
486        ));
487    }
488
489    #[test]
490    fn int_range_with_step() {
491        let s = parse_source("0..100 step 10").unwrap();
492        assert!(matches!(
493            s,
494            Source::IntRange {
495                lo: 0,
496                hi: 100,
497                step: 10
498            }
499        ));
500    }
501
502    #[test]
503    fn literal_int_list() {
504        let s = parse_source("[1, 2, 3]").unwrap();
505        match s {
506            Source::Literal { values } => {
507                assert_eq!(values.len(), 3);
508                assert_eq!(values[0], LiteralValue::Int(1));
509                assert_eq!(values[2], LiteralValue::Int(3));
510            }
511            other => panic!("expected Literal, got {other:?}"),
512        }
513    }
514
515    #[test]
516    fn bracket_bare_words_are_references_not_strings() {
517        // SRD-18f Stage 2: bare-word bracket elements are wire
518        // *references*, not string literals — so the list defers
519        // to a Generator (resolved at eval time) rather than
520        // baking `["a","b","c"]`. To get string literals, quote
521        // them (see `literal_quoted_strings`).
522        let s = parse_source("[a, b, c]").unwrap();
523        match s {
524            Source::Generator { expr, .. } => assert_eq!(expr, "[a, b, c]"),
525            other => panic!("expected deferred Generator, got {other:?}"),
526        }
527    }
528
529    #[test]
530    fn bracket_with_spread_defers_to_generator() {
531        let s = parse_source("[xs…]").unwrap();
532        assert!(
533            matches!(s, Source::Generator { .. }),
534            "spread list must defer: {s:?}"
535        );
536    }
537
538    #[test]
539    fn literal_quoted_strings() {
540        let s = parse_source(r#"["hello", "world"]"#).unwrap();
541        match s {
542            Source::Literal { values } => {
543                assert_eq!(values[0], LiteralValue::String("hello".into()));
544                assert_eq!(values[1], LiteralValue::String("world".into()));
545            }
546            other => panic!("expected Literal, got {other:?}"),
547        }
548    }
549
550    #[test]
551    fn literal_float_list() {
552        let s = parse_source("[1.5, 2.5, 3.5]").unwrap();
553        match s {
554            Source::Literal { values } => {
555                assert_eq!(values[0], LiteralValue::Float(1.5));
556            }
557            other => panic!("expected Literal, got {other:?}"),
558        }
559    }
560
561    #[test]
562    fn workload_param_ref() {
563        let s = parse_source("{profiles}").unwrap();
564        match s {
565            Source::WorkloadParamList { name, .. } => assert_eq!(name, "profiles"),
566            other => panic!("expected WorkloadParamList, got {other:?}"),
567        }
568    }
569
570    #[test]
571    fn generator_function_call() {
572        let s = parse_source("fib(8)").unwrap();
573        match s {
574            Source::Generator { expr, .. } => assert_eq!(expr, "fib(8)"),
575            other => panic!("expected Generator, got {other:?}"),
576        }
577    }
578
579    #[test]
580    fn continuous_interval_via_floats() {
581        let s = parse_source("0.0..1.0").unwrap();
582        match s {
583            Source::ContinuousInterval { interval, measure } => {
584                assert_eq!(interval.lo, 0.0);
585                assert_eq!(interval.hi, 1.0);
586                assert!(matches!(measure, ProductMeasure::Uniform));
587            }
588            other => panic!("expected ContinuousInterval, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn continuous_interval_inclusive() {
594        let s = parse_source("0.0..=1.0").unwrap();
595        match s {
596            Source::ContinuousInterval { interval, .. } => {
597                assert!(!interval.hi_open);
598            }
599            other => panic!("expected ContinuousInterval, got {other:?}"),
600        }
601    }
602
603    #[test]
604    fn unrecognized_source_falls_back_to_generator() {
605        // Previously: returned Err(Unrecognized). The legacy
606        // grammar accepts arbitrary expression text and the
607        // runtime evaluator resolves it via the Polydat Kernel
608        // chain, so unrecognized shapes pass through as a
609        // Generator expression rather than failing the parse.
610        let s = parse_source("totally nonsense").unwrap();
611        match s {
612            Source::Generator { expr, .. } => assert_eq!(expr, "totally nonsense"),
613            other => panic!("expected Generator, got {other:?}"),
614        }
615    }
616
617    #[test]
618    fn empty_literal_list() {
619        let s = parse_source("[]").unwrap();
620        match s {
621            Source::Literal { values } => assert!(values.is_empty()),
622            other => panic!("expected empty Literal, got {other:?}"),
623        }
624    }
625}