Skip to main content

polydat_grammar/comprehension/spec/
serde_form.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`ComprehensionSpec`] — author-friendly serde-deserializable
5//! form, plus its conversion into the algebra-layer AST.
6//!
7//! The friendly form has a **single `for` verb** (per user
8//! direction) whose RHS shape determines the constructor:
9//!
10//! - `for: "k in 1..10"` — single clause / inline cartesian
11//! - `for: ["k in 1..10", "limit in [1, 2, 3]"]` — list of
12//!   clauses, cartesian (or union if names repeat)
13//! - `for: [["k in 10", "limit in 1..5"], ["k in 100", "limit
14//!   in 1..50"]]` — explicit union of cartesian sub-spaces
15//!
16//! With optional modifiers:
17//!
18//! - `where: "..."` — filter predicate
19//! - `order: "halton/50"` — traversal order spec
20//!
21//! Conversion delegates to the existing legacy parsers + the
22//! [`super::legacy_convert`] bridge. This module owns only the
23//! serde surface and the shape-routing logic.
24
25use serde::{Deserialize, Serialize};
26
27use crate::comprehension::ast::Comprehension as AlgebraAst;
28use crate::comprehension::ast_legacy::{Clause as LegacyClause, Comprehension as LegacyAst};
29use crate::comprehension::parse::{
30    comprehension_from_subspaces, parse_clause_list, parse_order_spec,
31};
32
33use super::legacy_convert::{ConvertError, legacy_to_algebra};
34
35/// The friendly, serde-deserializable comprehension surface.
36///
37/// Field names match the YAML / JSON keys 1:1. The
38/// `for` field carries the only required input
39/// — the clause specification — in any of the three accepted
40/// shapes (see [`ForSpec`]).
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ComprehensionSpec {
43    /// The clause specification.  See [`ForSpec`] for the
44    /// accepted shapes.  `r#for` because `for` is a reserved
45    /// keyword in Rust — serde renames it for YAML / JSON.
46    #[serde(rename = "for")]
47    pub r#for: ForSpec,
48    /// Optional filter predicate (the `where` clause). String
49    /// form, evaluated against bound coordinates at iteration
50    /// time.
51    #[serde(default, rename = "where", skip_serializing_if = "Option::is_none")]
52    pub r#where: Option<String>,
53    /// Optional traversal-order spec. See
54    /// [`crate::comprehension::parse::parse_order_spec`] for
55    /// the accepted syntax (`lex`, `halton/50`,
56    /// `shells(origin=center, depth=3)`, etc.).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub order: Option<String>,
59}
60
61/// The three accepted shapes of the `for` field.
62///
63/// Routes through the legacy parser:
64///
65/// - [`ForSpec::Inline`] → one call to `parse_clause_list`,
66///   then the structural-detection rule (`comprehension_from_subspaces`).
67/// - [`ForSpec::ClauseList`] → one `parse_clause_list` per
68///   entry; each entry becomes its own sub-space (so name
69///   repetition across entries triggers Union per the rule).
70/// - [`ForSpec::UnionOfClauseLists`] → one `parse_clause_list`
71///   per inner list; each inner list is one sub-space.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(untagged)]
74pub enum ForSpec {
75    /// `for: "k in 1..10, limit in [1, 2, 3]"` — one inline
76    /// string, possibly multi-clause.
77    Inline(String),
78    /// `for: ["k in 1..10", "limit in [1, 2, 3]"]` — each
79    /// entry is one clause's text.
80    ClauseList(Vec<String>),
81    /// `for: [["k in 10", "limit in 1..5"], …]` — each inner
82    /// list is one sub-space (cartesian over those clauses).
83    UnionOfClauseLists(Vec<Vec<String>>),
84}
85
86/// Errors produced when converting a [`ComprehensionSpec`] to
87/// the algebra-layer AST.
88#[derive(Debug, Clone)]
89pub enum SpecConvertError {
90    /// `parse_clause_list` failed on one of the input strings.
91    ParseClause {
92        /// The clause text.
93        input: String,
94        /// The parser's message.
95        message: String,
96    },
97    /// `parse_order_spec` failed on the `order` field.
98    ParseOrder {
99        /// The order text.
100        input: String,
101        /// The parser's message.
102        message: String,
103    },
104    /// Legacy AST failed self-validation.
105    LegacyValidate {
106        /// The validation errors, in order.
107        errors: Vec<String>,
108    },
109    /// Conversion from legacy AST to algebra AST failed.
110    Convert(ConvertError),
111}
112
113impl std::fmt::Display for SpecConvertError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            SpecConvertError::ParseClause { input, message } => {
117                write!(f, "failed to parse clause(s) {input:?}: {message}")
118            }
119            SpecConvertError::ParseOrder { input, message } => {
120                write!(f, "failed to parse order {input:?}: {message}")
121            }
122            SpecConvertError::LegacyValidate { errors } => {
123                write!(f, "legacy AST validation failed: {}", errors.join("; "))
124            }
125            SpecConvertError::Convert(e) => {
126                write!(f, "algebra conversion failed: {e}")
127            }
128        }
129    }
130}
131
132impl std::error::Error for SpecConvertError {}
133
134impl From<ConvertError> for SpecConvertError {
135    fn from(e: ConvertError) -> Self {
136        SpecConvertError::Convert(e)
137    }
138}
139
140/// Parse a single inline `for`-clause string — **possibly
141/// multi-clause**, e.g. `"k in 1..10, limit in [1, 2, 3]"` — into the
142/// algebra-layer [`AlgebraAst`].
143///
144/// The canonical entry for consumers that hold only the for-clause text
145/// (no separate `where` / `order`): both **scenario- and phase-level**
146/// `for_each` in a host route through this, so the comprehension grammar
147/// has a single owner (polydat) rather than ad-hoc `var in expr` splits
148/// scattered in the runtime.
149pub fn parse_inline(spec: &str) -> Result<AlgebraAst, SpecConvertError> {
150    ComprehensionSpec {
151        r#for: ForSpec::Inline(spec.to_string()),
152        r#where: None,
153        order: None,
154    }
155    .into_algebra()
156}
157
158impl ComprehensionSpec {
159    /// Convert this spec into the algebra-layer
160    /// [`AlgebraAst`].  Routes the `for` shape through the
161    /// legacy parser, builds a legacy AST (applying `where` /
162    /// `order` modifiers), and runs the [`legacy_to_algebra`]
163    /// bridge.
164    pub fn into_algebra(self) -> Result<AlgebraAst, SpecConvertError> {
165        let legacy = self.into_legacy()?;
166        let algebra = legacy_to_algebra(&legacy)?;
167        Ok(algebra)
168    }
169
170    /// Build the intermediate legacy AST.  Exposed for tests
171    /// and for any consumer that still needs the legacy shape
172    /// (e.g., during incremental cutover).
173    pub fn into_legacy(self) -> Result<LegacyAst, SpecConvertError> {
174        let subspaces = self.r#for.into_subspaces()?;
175        let mut legacy = comprehension_from_subspaces(subspaces);
176        if let Some(predicate) = self.r#where {
177            legacy = legacy.with_filter(predicate);
178        }
179        if let Some(order_text) = self.order {
180            let order =
181                parse_order_spec(&order_text).map_err(|msg| SpecConvertError::ParseOrder {
182                    input: order_text.clone(),
183                    message: msg,
184                })?;
185            legacy = legacy.with_order(order);
186        }
187        legacy
188            .validate()
189            .map_err(|errs| SpecConvertError::LegacyValidate { errors: errs })?;
190        Ok(legacy)
191    }
192}
193
194impl ForSpec {
195    /// Lower a `ForSpec` to the `Vec<Vec<Clause>>` shape that
196    /// [`comprehension_from_subspaces`] expects.
197    fn into_subspaces(self) -> Result<Vec<Vec<LegacyClause>>, SpecConvertError> {
198        match self {
199            ForSpec::Inline(text) => {
200                // One inline string = one sub-space per clause
201                // (matches `parse_comprehension_text`'s
202                // convention so the union-detection rule sees
203                // per-clause boundaries).
204                let clauses =
205                    parse_clause_list(&text).map_err(|message| SpecConvertError::ParseClause {
206                        input: text.clone(),
207                        message,
208                    })?;
209                Ok(clauses.into_iter().map(|c| vec![c]).collect())
210            }
211            ForSpec::ClauseList(entries) => {
212                // Each entry is one clause's text — one
213                // sub-space per entry (same convention).
214                let mut subspaces = Vec::with_capacity(entries.len());
215                for entry in entries {
216                    let clauses = parse_clause_list(&entry).map_err(|message| {
217                        SpecConvertError::ParseClause {
218                            input: entry.clone(),
219                            message,
220                        }
221                    })?;
222                    for c in clauses {
223                        subspaces.push(vec![c]);
224                    }
225                }
226                Ok(subspaces)
227            }
228            ForSpec::UnionOfClauseLists(groups) => {
229                // Each inner list is one sub-space (cartesian
230                // over those clauses).
231                let mut subspaces = Vec::with_capacity(groups.len());
232                for group in groups {
233                    let mut subspace_clauses = Vec::with_capacity(group.len());
234                    for entry in group {
235                        let clauses = parse_clause_list(&entry).map_err(|message| {
236                            SpecConvertError::ParseClause {
237                                input: entry.clone(),
238                                message,
239                            }
240                        })?;
241                        subspace_clauses.extend(clauses);
242                    }
243                    subspaces.push(subspace_clauses);
244                }
245                Ok(subspaces)
246            }
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::comprehension::strategy::StrategyName;
255
256    #[test]
257    fn inline_single_clause() {
258        let yaml = r#"
259            for: "k in 1..10"
260        "#;
261        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
262        let algebra = spec.into_algebra().unwrap();
263        // Single-clause cartesian collapses to the bare Clause.
264        assert!(matches!(algebra, AlgebraAst::Clause { .. }));
265    }
266
267    #[test]
268    fn inline_multi_clause_cartesian() {
269        let yaml = r#"
270            for: "k in 1..10, limit in [10, 100, 1000]"
271        "#;
272        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
273        let algebra = spec.into_algebra().unwrap();
274        match algebra {
275            AlgebraAst::Cartesian { children } => assert_eq!(children.len(), 2),
276            other => panic!("expected Cartesian, got {other:?}"),
277        }
278    }
279
280    #[test]
281    fn clause_list_form_cartesian() {
282        let yaml = r#"
283            for:
284              - "k in 1..10"
285              - "limit in [10, 100, 1000]"
286        "#;
287        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
288        let algebra = spec.into_algebra().unwrap();
289        match algebra {
290            AlgebraAst::Cartesian { children } => assert_eq!(children.len(), 2),
291            other => panic!("expected Cartesian, got {other:?}"),
292        }
293    }
294
295    #[test]
296    fn union_of_clause_lists() {
297        let yaml = r#"
298            for:
299              - ["k in 10",  "limit in [1, 2, 3]"]
300              - ["k in 100", "limit in [10, 20, 30]"]
301        "#;
302        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
303        let algebra = spec.into_algebra().unwrap();
304        match algebra {
305            AlgebraAst::Union { children } => assert_eq!(children.len(), 2),
306            other => panic!("expected Union, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn where_clause_wraps_with_filter() {
312        let yaml = r#"
313            for: "k in 1..10"
314            where: "{k} > 5"
315        "#;
316        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
317        let algebra = spec.into_algebra().unwrap();
318        assert!(matches!(algebra, AlgebraAst::Filter { .. }));
319    }
320
321    #[test]
322    fn order_clause_wraps_with_order() {
323        let yaml = r#"
324            for: "k in 1..10, limit in 1..100"
325            order: "halton/50"
326        "#;
327        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
328        let algebra = spec.into_algebra().unwrap();
329        match algebra {
330            AlgebraAst::Order {
331                strategy: StrategyName::Halton,
332                truncation: Some(50),
333                ..
334            } => {}
335            other => panic!("expected Order(Halton, Some(50)), got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn where_and_order_compose() {
341        let yaml = r#"
342            for: "k in 1..10, limit in 1..100"
343            where: "{k} * {limit} <= 100"
344            order: "lex/20"
345        "#;
346        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
347        let algebra = spec.into_algebra().unwrap();
348        // Order wraps Filter wraps Cartesian
349        match algebra {
350            AlgebraAst::Order {
351                child,
352                strategy: StrategyName::Lex,
353                truncation: Some(20),
354                ..
355            } => {
356                assert!(matches!(*child, AlgebraAst::Filter { .. }));
357            }
358            other => panic!("expected Order(Lex, Some(20)) wrapping Filter, got {other:?}"),
359        }
360    }
361
362    #[test]
363    fn json_input_round_trips() {
364        let json = r#"
365            {
366                "for": ["k in 1..10", "limit in [10, 100]"],
367                "where": "{k} > 0",
368                "order": "halton/20"
369            }
370        "#;
371        let spec: ComprehensionSpec = serde_json::from_str(json).unwrap();
372        let algebra = spec.into_algebra().unwrap();
373        assert!(matches!(algebra, AlgebraAst::Order { .. }));
374    }
375
376    #[test]
377    fn malformed_clause_surfaces_error() {
378        let yaml = r#"
379            for: "this is not a valid clause"
380        "#;
381        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
382        let err = spec.into_algebra().unwrap_err();
383        assert!(matches!(err, SpecConvertError::ParseClause { .. }));
384    }
385
386    #[test]
387    fn malformed_order_surfaces_error() {
388        let yaml = r#"
389            for: "k in 1..10"
390            order: "(((not valid"
391        "#;
392        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
393        let err = spec.into_algebra().unwrap_err();
394        assert!(matches!(err, SpecConvertError::ParseOrder { .. }));
395    }
396
397    #[test]
398    fn unparseable_source_inside_for_falls_back_to_generator() {
399        // parse_source now treats unrecognized text as a
400        // Source::Generator that the runtime evaluator resolves
401        // against the Polydat Kernel chain — matching the legacy
402        // grammar's permissive accept-anything behavior.
403        let yaml = r#"
404            for: "k in something-weird"
405        "#;
406        let spec: ComprehensionSpec = serde_yaml::from_str(yaml).unwrap();
407        let algebra = spec.into_algebra().expect("permissive accept");
408        match algebra {
409            AlgebraAst::Clause { source, .. } => match source {
410                crate::comprehension::source::Source::Generator { expr, .. } => {
411                    assert_eq!(expr, "something-weird");
412                }
413                other => panic!("expected Generator, got {other:?}"),
414            },
415            other => panic!("expected Clause, got {other:?}"),
416        }
417    }
418}