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(_) | LiteralValue::Float(_) | LiteralValue::Bool(_) => true,
116        LiteralValue::String(s) => {
117            let trimmed = s.trim();
118            !trimmed.is_empty()
119                && trimmed == s
120                && !s.contains([
121                    '(', ')', '[', ']', '{', '}', '\'', '"', '+', '*', '/', '%', '=', '<', '>',
122                    '!', '&', '|', '~', '^', '?', ',', '\n',
123                ])
124                && s.parse::<i64>().is_err()
125                && s.parse::<f64>().is_err()
126                && !s.eq_ignore_ascii_case("true")
127                && !s.eq_ignore_ascii_case("false")
128        }
129        LiteralValue::Json(_) => false,
130    }
131}
132
133/// One value as a bare list writes it.
134fn bare_value_text(v: &LiteralValue) -> Option<String> {
135    Some(match v {
136        LiteralValue::Int(i) => i.to_string(),
137        LiteralValue::Float(f) => format!("{f:?}"),
138        LiteralValue::Bool(b) => b.to_string(),
139        LiteralValue::String(s) => s.clone(),
140        LiteralValue::Json(_) => return None,
141    })
142}
143
144/// One value as a bracketed list writes it, strings quoted. `None`
145/// when the text cannot: a JSON value has no literal spelling, and a
146/// string carrying a quote, a comma, or a bracket is not read back as
147/// one value.
148fn quoted_value_text(v: &LiteralValue) -> Option<String> {
149    Some(match v {
150        LiteralValue::String(s) => {
151            if s.contains(['"', '\'', ',', '[', ']', '{', '}', '(', ')', '\n']) {
152                return None;
153            }
154            format!("\"{s}\"")
155        }
156        other => bare_value_text(other)?,
157    })
158}
159
160impl Comprehension {
161    /// The comprehension's canonical text (comprehension_forms.md §8),
162    /// or `None` when the tree's shape, or a source in it, is outside
163    /// the text grammar.
164    ///
165    /// Parsing the text this returns yields a tree equal to this one,
166    /// and rendering that tree returns the same text.
167    pub fn to_text(&self) -> Option<String> {
168        match self {
169            Comprehension::Order {
170                child,
171                strategy,
172                truncation,
173                seed,
174            } => {
175                let head = match &**child {
176                    Comprehension::Filter { child, predicate } => {
177                        format!("{} where {predicate}", child.body_text()?)
178                    }
179                    other => other.body_text()?,
180                };
181                Some(format!(
182                    "{head} order {}",
183                    order_text(*strategy, *truncation, *seed)
184                ))
185            }
186            Comprehension::Filter { child, predicate } => {
187                Some(format!("{} where {predicate}", child.body_text()?))
188            }
189            other => other.body_text(),
190        }
191    }
192
193    /// The text of a comprehension's body: the clause list or the
194    /// bracketed union, without a `where` or an `order`, which only
195    /// the outermost level carries.
196    fn body_text(&self) -> Option<String> {
197        match self {
198            Comprehension::Clause { .. } | Comprehension::Zip { .. } => self.clause_text(),
199            Comprehension::Cartesian { children } => children
200                .iter()
201                .map(Comprehension::clause_text)
202                .collect::<Option<Vec<_>>>()
203                .map(|clauses| clauses.join(", ")),
204            Comprehension::Union { children } => {
205                let members = children
206                    .iter()
207                    .map(|c| c.to_text().map(|text| format!("for {text}")))
208                    .collect::<Option<Vec<_>>>()?;
209                Some(format!("[ {} ]", members.join(", ")))
210            }
211            // A filter or an order inside a body: the text carries each
212            // once, at the end, so there is no place to write it.
213            Comprehension::Filter { .. } | Comprehension::Order { .. } => None,
214        }
215    }
216
217    /// The text of one clause of a clause list: `name in source`, or
218    /// the tuple form `(a, b) in (…)` for a zip of single clauses.
219    fn clause_text(&self) -> Option<String> {
220        match self {
221            Comprehension::Clause { name, source } => {
222                Some(format!("{name} in {}", source.to_text()?))
223            }
224            Comprehension::Zip { children, mode } => {
225                if children.len() < 2 {
226                    return None;
227                }
228                let mut names = Vec::with_capacity(children.len());
229                let mut sources = Vec::with_capacity(children.len());
230                for child in children {
231                    let Comprehension::Clause { name, source } = child else {
232                        return None;
233                    };
234                    names.push(name.clone());
235                    sources.push(source.to_text()?);
236                }
237                let inner = sources.join(", ");
238                let rhs = match mode {
239                    ZipMode::Strict => format!("({inner})"),
240                    ZipMode::Truncate => format!("zip_truncate({inner})"),
241                    ZipMode::Cycle => format!("zip_cycle({inner})"),
242                };
243                Some(format!("({}) in {rhs}", names.join(", ")))
244            }
245            _ => None,
246        }
247    }
248}
249
250/// An order's text: the terse `name/n` form, and the keyword form when
251/// a seeded strategy carries an authored seed, which is what the order
252/// parser reads back.
253fn order_text(strategy: StrategyName, truncation: Option<u64>, seed: Option<u64>) -> String {
254    let name = strategy.as_str();
255    match (truncation, seed) {
256        (Some(n), Some(s)) => format!("{name}(count={n}, seed={s})"),
257        (None, Some(s)) => format!("{name}(seed={s})"),
258        (Some(n), None) => format!("{name}/{n}"),
259        (None, None) => name.to_string(),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::comprehension::cardinality::{Interval, MeasureName, ProductMeasure};
267    use crate::comprehension::spec::{parse_comprehension_algebra, parse_source};
268
269    /// Every source the text can write round-trips through its text.
270    #[test]
271    fn every_source_round_trips_through_its_text() {
272        let sources = [
273            Source::IntRange {
274                lo: 1,
275                hi: 10,
276                step: 1,
277            },
278            Source::IntRange {
279                lo: 0,
280                hi: 20,
281                step: 5,
282            },
283            Source::Literal {
284                values: vec![LiteralValue::Int(1), LiteralValue::Int(2)],
285            },
286            Source::Literal {
287                values: vec![LiteralValue::String("load".into())],
288            },
289            Source::Literal {
290                values: vec![
291                    LiteralValue::String("load".into()),
292                    LiteralValue::String("verify".into()),
293                ],
294            },
295            Source::Literal {
296                values: vec![LiteralValue::Float(1.5), LiteralValue::Float(2.0)],
297            },
298            Source::Literal {
299                values: vec![LiteralValue::Bool(true), LiteralValue::Bool(false)],
300            },
301            Source::Generator {
302                expr: "partitions(\"*/4\", 100)".into(),
303                cardinality_hint: None,
304            },
305            Source::WorkloadParamList {
306                name: "total".into(),
307                len_hint: None,
308            },
309            Source::ContinuousInterval {
310                interval: Interval::half_open(0.0, 1.0),
311                measure: ProductMeasure::Uniform,
312            },
313            Source::Distribution {
314                distribution: MeasureName::Normal,
315                support: MeasureName::Normal.support(&[0.0, 1.0]),
316                params: vec![0.0, 1.0],
317            },
318            Source::Distribution {
319                distribution: MeasureName::Exponential,
320                support: Interval::half_open(0.0, 1.0),
321                params: vec![1.0],
322            },
323        ];
324        for source in sources {
325            let text = source.to_text().expect("the text writes this source");
326            let back = parse_source(&text).unwrap_or_else(|e| panic!("`{text}`: {e:?}"));
327            assert_eq!(back, source, "`{text}`");
328            assert_eq!(back.to_text(), Some(text));
329        }
330    }
331
332    /// A value the text cannot write has no text, rather than one that
333    /// reads back as something else.
334    #[test]
335    fn a_source_the_text_cannot_write_has_no_text() {
336        for value in [
337            LiteralValue::Json(serde_json::json!({"a": 1})),
338            LiteralValue::String("a,b".into()),
339            LiteralValue::String("say \"hi\"".into()),
340            LiteralValue::String("[bracketed]".into()),
341        ] {
342            let source = Source::Literal {
343                values: vec![value.clone()],
344            };
345            assert_eq!(source.to_text(), None, "{value:?}");
346        }
347    }
348
349    /// Canonical text is what the tree renders to and what parses back
350    /// to it: text → tree → text is stable, and tree → text → tree is
351    /// the identity.
352    #[test]
353    fn canonical_text_round_trips_through_the_grammar() {
354        let texts = [
355            "k in 1..4",
356            "k in 1..10..2",
357            "k in 1, 2, 4",
358            "k in load, verify",
359            "x in 0.0..1.0",
360            "x in normal(0.0, 1.0)",
361            "x in exponential(1.0) on 0.0..1.0",
362            "k in 1..4, limit in 10, 20",
363            "(a, b) in (1..4, 10..13)",
364            "(a, b) in zip_truncate(1..4, 10..20)",
365            "(a, b) in zip_cycle(1..4, 10..20)",
366            "k in 1..9 where {k} > 2",
367            "k in 1..9 order lex",
368            "k in 1..9 order halton/3",
369            "k in 1..9 order shuffle(count=3, seed=42)",
370            "k in 1..9 order lhs(seed=7)",
371            "k in 1..9 where {k} > 2 order halton/2",
372            "[ for k in 1..4, for k in 10..13 ]",
373            "[ for k in 1..4 where {k} > 1, for k in 10..13 order lex/2 ]",
374            "[ for k in 1..4, for k in 10..13 ] where {k} > 2 order halton/2",
375        ];
376        for text in texts {
377            let tree =
378                parse_comprehension_algebra(text).unwrap_or_else(|e| panic!("`{text}`: {e}"));
379            let rendered = tree
380                .to_text()
381                .unwrap_or_else(|| panic!("`{text}` has no canonical text"));
382            assert_eq!(rendered, text, "canonical text is not the written text");
383            let back = parse_comprehension_algebra(&rendered)
384                .unwrap_or_else(|e| panic!("`{rendered}`: {e}"));
385            assert_eq!(back, tree, "`{rendered}` parses to a different tree");
386            assert_eq!(back.to_text().as_deref(), Some(rendered.as_str()));
387        }
388    }
389
390    /// A tree the text grammar cannot spell has no text.
391    #[test]
392    fn a_tree_outside_the_text_grammar_has_no_text() {
393        let clause = |name: &str| {
394            Comprehension::clause(
395                name,
396                Source::IntRange {
397                    lo: 1,
398                    hi: 4,
399                    step: 1,
400                },
401            )
402        };
403        // A filter under a cartesian: the text carries one `where`, at
404        // the end, over the whole clause list.
405        let inner_filter = Comprehension::cartesian(vec![
406            Comprehension::filter(clause("k"), "{k} > 1"),
407            clause("j"),
408        ]);
409        assert_eq!(inner_filter.to_text(), None);
410        // An order under a zip, a nested cartesian, and a one-child zip.
411        let ordered_zip = Comprehension::zip(
412            vec![
413                Comprehension::order(clause("k"), StrategyName::Lex, Some(2)),
414                clause("j"),
415            ],
416            ZipMode::Strict,
417        );
418        assert_eq!(ordered_zip.to_text(), None);
419        let nested = Comprehension::cartesian(vec![
420            Comprehension::cartesian(vec![clause("k"), clause("j")]),
421            clause("m"),
422        ]);
423        assert_eq!(nested.to_text(), None);
424        assert_eq!(
425            Comprehension::zip(vec![clause("k")], ZipMode::Strict).to_text(),
426            None
427        );
428    }
429}