Skip to main content

polydat_grammar/comprehension/
to_text.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The canonical text of a source and of a comprehension
5//! (comprehension_forms.md §8): the rendering that
6//! [`spec::parse_source`](crate::comprehension::spec::parse_source) and
7//! [`spec::parse_comprehension_algebra`](crate::comprehension::spec::parse_comprehension_algebra)
8//! read back to an equal source and an equal tree, so a comprehension
9//! built programmatically carries the text a written one carries.
10//!
11//! Both renderings answer `None` rather than a spelling the parser
12//! would read back as something else. A source has no text when a
13//! literal holds a value the text cannot write: a JSON value, or a
14//! string carrying a quote, a comma, or a bracket. A tree has no text
15//! when its shape is outside the text grammar's — a clause list or a
16//! bracketed union, each with an optional `where` and `order` — so a
17//! filter under a cartesian, an order under a zip, and a nested
18//! cartesian have none.
19
20use super::ast::Comprehension;
21use super::source::{LiteralValue, Source};
22use super::strategy::{StrategyName, ZipMode};
23
24impl Source {
25    /// The source's canonical text, what a clause writes after `in`,
26    /// or `None` when the text cannot write it.
27    pub fn to_text(&self) -> Option<String> {
28        Some(match self {
29            Source::IntRange { lo, hi, step } => {
30                if *step == 1 {
31                    format!("{lo}..{hi}")
32                } else {
33                    format!("{lo}..{hi}..{step}")
34                }
35            }
36            // The bare list the grammar's own examples write, when
37            // every value reads back from it; the bracketed, quoted
38            // form otherwise, which carries a string a bare list
39            // cannot.
40            Source::Literal { values } => literal_list_text(values)?,
41            Source::Generator { expr, .. } => expr.clone(),
42            Source::WorkloadParamList { name, .. } => format!("{{{name}}}"),
43            Source::ContinuousInterval { interval, .. } => {
44                // `{:?}` (not `{}`) keeps the decimal point so the
45                // endpoints re-parse as floats — `{}` on `1.0f64` prints
46                // "1", round-tripping a continuous `[1.0,5.0)` to
47                // "1..5", which the source parser would re-classify as
48                // an integer range (typing the element `u64`).
49                if interval.hi_open {
50                    format!("{:?}..{:?}", interval.lo, interval.hi)
51                } else {
52                    format!("{:?}..={:?}", interval.lo, interval.hi)
53                }
54            }
55            Source::Distribution {
56                distribution,
57                support,
58                params,
59            } => {
60                // The parameters in the measure's own order, and the
61                // interval only when it narrows the measure's own
62                // support.
63                let args = params
64                    .iter()
65                    .map(|p| format!("{p:?}"))
66                    .collect::<Vec<_>>()
67                    .join(", ");
68                let call = format!("{}({args})", distribution.text());
69                if *support == distribution.support(params) {
70                    call
71                } else if support.hi_open {
72                    format!("{call} on {:?}..{:?}", support.lo, support.hi)
73                } else {
74                    format!("{call} on {:?}..={:?}", support.lo, support.hi)
75                }
76            }
77        })
78    }
79}
80
81/// A literal list as the text writes it: the bare comma list the
82/// grammar's own examples use when every value reads back from it, and
83/// the bracketed, quoted form otherwise. `None` when neither reads
84/// back: a JSON value, or a string carrying a quote, a comma, or a
85/// bracket.
86fn literal_list_text(values: &[LiteralValue]) -> Option<String> {
87    if values.is_empty() {
88        return None;
89    }
90    // A bare single string is an identifier, which reads back as a
91    // generator call, so one string takes the bracketed form; one
92    // number or boolean is a scalar of its own.
93    let bare_ok = values.iter().all(bare_value_is_unambiguous)
94        && (values.len() > 1 || !matches!(values[0], LiteralValue::String(_)));
95    if bare_ok {
96        let items = values
97            .iter()
98            .map(bare_value_text)
99            .collect::<Option<Vec<_>>>()?;
100        return Some(items.join(", "));
101    }
102    let items = values
103        .iter()
104        .map(quoted_value_text)
105        .collect::<Option<Vec<_>>>()?;
106    Some(format!("[{}]", items.join(", ")))
107}
108
109/// Whether a value reads back from a bare list: a number or a boolean
110/// always does, and a string does when it carries none of the
111/// characters the bare form refuses and does not read back as a number
112/// or a boolean instead.
113fn bare_value_is_unambiguous(v: &LiteralValue) -> bool {
114    match v {
115        LiteralValue::Int(_)
116        | LiteralValue::UInt(_)
117        | LiteralValue::Float(_)
118        | LiteralValue::Bool(_) => true,
119        LiteralValue::String(s) => {
120            let trimmed = s.trim();
121            !trimmed.is_empty()
122                && trimmed == s
123                && !s.contains([
124                    '(', ')', '[', ']', '{', '}', '\'', '"', '+', '*', '/', '%', '=', '<', '>',
125                    '!', '&', '|', '~', '^', '?', ',', '\n',
126                ])
127                && s.parse::<i64>().is_err()
128                && s.parse::<f64>().is_err()
129                && !s.eq_ignore_ascii_case("true")
130                && !s.eq_ignore_ascii_case("false")
131        }
132        LiteralValue::Json(_) => false,
133    }
134}
135
136/// One value as a bare list writes it.
137fn bare_value_text(v: &LiteralValue) -> Option<String> {
138    Some(match v {
139        LiteralValue::Int(i) => i.to_string(),
140        LiteralValue::UInt(u) => u.to_string(),
141        LiteralValue::Float(f) => format!("{f:?}"),
142        LiteralValue::Bool(b) => b.to_string(),
143        LiteralValue::String(s) => s.clone(),
144        LiteralValue::Json(_) => return None,
145    })
146}
147
148/// One value as a bracketed list writes it, strings quoted. `None`
149/// when the text cannot: a JSON value has no literal spelling, and a
150/// string carrying a quote, a comma, or a bracket is not read back as
151/// one value.
152fn quoted_value_text(v: &LiteralValue) -> Option<String> {
153    Some(match v {
154        LiteralValue::String(s) => {
155            if s.contains(['"', '\'', ',', '[', ']', '{', '}', '(', ')', '\n']) {
156                return None;
157            }
158            format!("\"{s}\"")
159        }
160        other => bare_value_text(other)?,
161    })
162}
163
164impl Comprehension {
165    /// The comprehension's canonical text (comprehension_forms.md §8),
166    /// or `None` when the tree's shape, or a source in it, is outside
167    /// the text grammar.
168    ///
169    /// Parsing the text this returns yields a tree equal to this one,
170    /// and rendering that tree returns the same text.
171    pub fn to_text(&self) -> Option<String> {
172        match self {
173            Comprehension::Order {
174                child,
175                strategy,
176                truncation,
177                seed,
178            } => {
179                let head = match &**child {
180                    Comprehension::Filter { child, predicate } => {
181                        format!("{} where {predicate}", child.body_text()?)
182                    }
183                    other => other.body_text()?,
184                };
185                Some(format!(
186                    "{head} order {}",
187                    order_text(*strategy, *truncation, *seed)
188                ))
189            }
190            Comprehension::Filter { child, predicate } => {
191                Some(format!("{} where {predicate}", child.body_text()?))
192            }
193            other => other.body_text(),
194        }
195    }
196
197    /// The text of a comprehension's body: the clause list or the
198    /// bracketed union, without a `where` or an `order`, which only
199    /// the outermost level carries.
200    fn body_text(&self) -> Option<String> {
201        match self {
202            Comprehension::Clause { .. } | Comprehension::Zip { .. } => self.clause_text(),
203            Comprehension::Cartesian { children } => children
204                .iter()
205                .map(Comprehension::clause_text)
206                .collect::<Option<Vec<_>>>()
207                .map(|clauses| clauses.join(", ")),
208            Comprehension::Union { children } => {
209                let members = children
210                    .iter()
211                    .map(|c| c.to_text().map(|text| format!("for {text}")))
212                    .collect::<Option<Vec<_>>>()?;
213                Some(format!("[ {} ]", members.join(", ")))
214            }
215            // A filter or an order inside a body: the text carries each
216            // once, at the end, so there is no place to write it.
217            Comprehension::Filter { .. } | Comprehension::Order { .. } => None,
218        }
219    }
220
221    /// The text of one clause of a clause list: `name in source`, or
222    /// the tuple form `(a, b) in (…)` for a zip of single clauses.
223    fn clause_text(&self) -> Option<String> {
224        match self {
225            Comprehension::Clause { name, source } => {
226                Some(format!("{name} in {}", source.to_text()?))
227            }
228            Comprehension::Zip { children, mode } => {
229                if children.len() < 2 {
230                    return None;
231                }
232                let mut names = Vec::with_capacity(children.len());
233                let mut sources = Vec::with_capacity(children.len());
234                for child in children {
235                    let Comprehension::Clause { name, source } = child else {
236                        return None;
237                    };
238                    names.push(name.clone());
239                    sources.push(source.to_text()?);
240                }
241                let inner = sources.join(", ");
242                let rhs = match mode {
243                    ZipMode::Strict => format!("({inner})"),
244                    ZipMode::Truncate => format!("zip_truncate({inner})"),
245                    ZipMode::Cycle => format!("zip_cycle({inner})"),
246                };
247                Some(format!("({}) in {rhs}", names.join(", ")))
248            }
249            _ => None,
250        }
251    }
252}
253
254/// An order's text: the terse `name/n` form, and the keyword form when
255/// a seeded strategy carries an authored seed, which is what the order
256/// parser reads back.
257fn order_text(strategy: StrategyName, truncation: Option<u64>, seed: Option<u64>) -> String {
258    let name = strategy.as_str();
259    match (truncation, seed) {
260        (Some(n), Some(s)) => format!("{name}(count={n}, seed={s})"),
261        (None, Some(s)) => format!("{name}(seed={s})"),
262        (Some(n), None) => format!("{name}/{n}"),
263        (None, None) => name.to_string(),
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::comprehension::cardinality::{Interval, MeasureName, ProductMeasure};
271    use crate::comprehension::spec::{parse_comprehension_algebra, parse_source};
272
273    /// Every source the text can write round-trips through its text.
274    #[test]
275    fn every_source_round_trips_through_its_text() {
276        let sources = [
277            Source::IntRange {
278                lo: 1,
279                hi: 10,
280                step: 1,
281            },
282            Source::IntRange {
283                lo: 0,
284                hi: 20,
285                step: 5,
286            },
287            Source::Literal {
288                values: vec![LiteralValue::Int(1), LiteralValue::Int(2)],
289            },
290            Source::Literal {
291                values: vec![
292                    LiteralValue::Int(i64::MAX),
293                    LiteralValue::UInt(1 << 63),
294                    LiteralValue::UInt(u64::MAX),
295                ],
296            },
297            Source::Literal {
298                values: vec![LiteralValue::String("load".into())],
299            },
300            Source::Literal {
301                values: vec![
302                    LiteralValue::String("load".into()),
303                    LiteralValue::String("verify".into()),
304                ],
305            },
306            Source::Literal {
307                values: vec![LiteralValue::Float(1.5), LiteralValue::Float(2.0)],
308            },
309            Source::Literal {
310                values: vec![LiteralValue::Bool(true), LiteralValue::Bool(false)],
311            },
312            Source::Generator {
313                expr: "partitions(\"*/4\", 100)".into(),
314                cardinality_hint: None,
315            },
316            Source::WorkloadParamList {
317                name: "total".into(),
318                len_hint: None,
319            },
320            Source::ContinuousInterval {
321                interval: Interval::half_open(0.0, 1.0),
322                measure: ProductMeasure::Uniform,
323            },
324            Source::Distribution {
325                distribution: MeasureName::Normal,
326                support: MeasureName::Normal.support(&[0.0, 1.0]),
327                params: vec![0.0, 1.0],
328            },
329            Source::Distribution {
330                distribution: MeasureName::Exponential,
331                support: Interval::half_open(0.0, 1.0),
332                params: vec![1.0],
333            },
334        ];
335        for source in sources {
336            let text = source.to_text().expect("the text writes this source");
337            let back = parse_source(&text).unwrap_or_else(|e| panic!("`{text}`: {e:?}"));
338            assert_eq!(back, source, "`{text}`");
339            assert_eq!(back.to_text(), Some(text));
340        }
341    }
342
343    /// A value the text cannot write has no text, rather than one that
344    /// reads back as something else.
345    #[test]
346    fn a_source_the_text_cannot_write_has_no_text() {
347        for value in [
348            LiteralValue::Json(serde_json::json!({"a": 1})),
349            LiteralValue::String("a,b".into()),
350            LiteralValue::String("say \"hi\"".into()),
351            LiteralValue::String("[bracketed]".into()),
352        ] {
353            let source = Source::Literal {
354                values: vec![value.clone()],
355            };
356            assert_eq!(source.to_text(), None, "{value:?}");
357        }
358    }
359
360    /// Canonical text is what the tree renders to and what parses back
361    /// to it: text → tree → text is stable, and tree → text → tree is
362    /// the identity.
363    #[test]
364    fn canonical_text_round_trips_through_the_grammar() {
365        let texts = [
366            "k in 1..4",
367            "k in 1..10..2",
368            "k in 1, 2, 4",
369            "k in load, verify",
370            "x in 0.0..1.0",
371            "x in normal(0.0, 1.0)",
372            "x in exponential(1.0) on 0.0..1.0",
373            "k in 1..4, limit in 10, 20",
374            "(a, b) in (1..4, 10..13)",
375            "(a, b) in zip_truncate(1..4, 10..20)",
376            "(a, b) in zip_cycle(1..4, 10..20)",
377            "k in 1..9 where {k} > 2",
378            "k in 1..9 order lex",
379            "k in 1..9 order halton/3",
380            "k in 1..9 order shuffle(count=3, seed=42)",
381            "k in 1..9 order lhs(seed=7)",
382            "k in 1..9 where {k} > 2 order halton/2",
383            "[ for k in 1..4, for k in 10..13 ]",
384            "[ for k in 1..4 where {k} > 1, for k in 10..13 order lex/2 ]",
385            "[ for k in 1..4, for k in 10..13 ] where {k} > 2 order halton/2",
386        ];
387        for text in texts {
388            let tree =
389                parse_comprehension_algebra(text).unwrap_or_else(|e| panic!("`{text}`: {e}"));
390            let rendered = tree
391                .to_text()
392                .unwrap_or_else(|| panic!("`{text}` has no canonical text"));
393            assert_eq!(rendered, text, "canonical text is not the written text");
394            let back = parse_comprehension_algebra(&rendered)
395                .unwrap_or_else(|e| panic!("`{rendered}`: {e}"));
396            assert_eq!(back, tree, "`{rendered}` parses to a different tree");
397            assert_eq!(back.to_text().as_deref(), Some(rendered.as_str()));
398        }
399    }
400
401    /// A tree the text grammar cannot spell has no text.
402    #[test]
403    fn a_tree_outside_the_text_grammar_has_no_text() {
404        let clause = |name: &str| {
405            Comprehension::clause(
406                name,
407                Source::IntRange {
408                    lo: 1,
409                    hi: 4,
410                    step: 1,
411                },
412            )
413        };
414        // A filter under a cartesian: the text carries one `where`, at
415        // the end, over the whole clause list.
416        let inner_filter = Comprehension::cartesian(vec![
417            Comprehension::filter(clause("k"), "{k} > 1"),
418            clause("j"),
419        ]);
420        assert_eq!(inner_filter.to_text(), None);
421        // An order under a zip, a nested cartesian, and a one-child zip.
422        let ordered_zip = Comprehension::zip(
423            vec![
424                Comprehension::order(clause("k"), StrategyName::Lex, Some(2)),
425                clause("j"),
426            ],
427            ZipMode::Strict,
428        );
429        assert_eq!(ordered_zip.to_text(), None);
430        let nested = Comprehension::cartesian(vec![
431            Comprehension::cartesian(vec![clause("k"), clause("j")]),
432            clause("m"),
433        ]);
434        assert_eq!(nested.to_text(), None);
435        assert_eq!(
436            Comprehension::zip(vec![clause("k")], ZipMode::Strict).to_text(),
437            None
438        );
439    }
440}