Skip to main content

polydat_grammar/
pprint.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! AST → `.polydat` source pretty-printer.
5//!
6//! The printer gives a compiled `for` body its source text for
7//! diagnostics (`pp_file`), re-emits rewritten expressions in
8//! module inlining and tile lowering (`pp_expr`), and prints
9//! expressions in `polydat explain`. The runtime compiles from the
10//! AST; the printer is a faithful AST → source round-trip beside it.
11//!
12//! ## Round-trip contract
13//!
14//! For every `Statement`/`Expr` produced by the parser,
15//! `pp_statement` / `pp_expr` produces source text that re-parses
16//! into a semantically equivalent AST. "Semantically equivalent"
17//! means same node types and identical inner data (modulo
18//! `Span`s, which capture parser position and are not preserved
19//! across re-parse).
20//!
21//! ## Precedence and parens
22//!
23//! `BinOp` expressions are emitted with parens around the whole
24//! expression. This is uniformly safe — re-parsing produces the
25//! same tree structure — at the cost of extra parens. The output
26//! is the canonical spelling; the parens are uniform for round-trip
27//! safety.
28
29use crate::ast::{
30    Arg, BinOpKind, Binding, BindingModifier, CallExpr, CursorDecl, Expr, ExternPort, ForStmt,
31    ModuleDef, PolydatFile, Statement, TileBodyKind, TileDef, TileOptions, WireModifier,
32};
33
34/// Pretty-print a full file: every statement, separated by
35/// newlines.
36pub fn pp_file(file: &PolydatFile) -> String {
37    let mut out = String::new();
38    for stmt in &file.statements {
39        out.push_str(&pp_statement(stmt));
40        out.push('\n');
41    }
42    out
43}
44
45/// Pretty-print a top-level statement.
46pub fn pp_statement(stmt: &Statement) -> String {
47    match stmt {
48        Statement::InputDecl(d) => match &d.ty {
49            Some(ty) => format!("input {}: {}", d.name, ty),
50            None => format!("input {}", d.name),
51        },
52        Statement::Binding(b) => pp_binding(b),
53        Statement::ModuleDef(m) => pp_module_def(m),
54        Statement::ExternPort(p) => pp_extern_port(p),
55        Statement::Cursor(c) => pp_cursor(c),
56        Statement::Pragma { name, .. } => format!("pragma {name}"),
57        Statement::For(f) => pp_for_stmt(f, 0),
58        Statement::Tile(t) => pp_tile(t),
59    }
60}
61
62fn pp_tile(t: &TileDef) -> String {
63    let mut out = format!("tile {}", t.name);
64    if let Some(enc) = &t.encoding {
65        out.push_str(&format!(" : {enc}"));
66    }
67    let defaults = TileOptions::default();
68    let mut opts = Vec::new();
69    if t.options.open != defaults.open || t.options.close != defaults.close {
70        opts.push(format!(
71            "delims \"{}\" \"{}\"",
72            escape_string(&t.options.open),
73            escape_string(&t.options.close)
74        ));
75    }
76    if t.options.sigil != defaults.sigil {
77        opts.push(format!("sigil \"{}\"", escape_string(&t.options.sigil)));
78    }
79    if t.options.strict {
80        opts.push("strict".to_string());
81    }
82    if t.options.in_string {
83        opts.push("instring".to_string());
84    }
85    if !opts.is_empty() {
86        out.push_str(&format!(" ({})", opts.join(", ")));
87    }
88    // A tile binds a wire: `:=` precedes every body form.
89    match t.body_kind {
90        TileBodyKind::Block => {
91            out.push_str(" := ");
92            out.push_str(&t.body);
93        }
94        TileBodyKind::Heredoc => {
95            out.push_str(" := <<<\n");
96            out.push_str(&t.body);
97            out.push_str("\n>>>");
98        }
99        TileBodyKind::Literal => out.push_str(&format!(" := \"{}\"", escape_string(&t.body))),
100    }
101    out
102}
103
104fn pp_for_stmt(f: &ForStmt, indent: usize) -> String {
105    let pad = "    ".repeat(indent + 1);
106    let mut body = String::new();
107    for s in &f.body {
108        body.push_str(&pad);
109        body.push_str(&match s {
110            Statement::For(inner) => pp_for_stmt(inner, indent + 1),
111            other => pp_statement(other),
112        });
113        body.push('\n');
114    }
115    format!(
116        "for {} {{\n{}{}}}",
117        f.source.text,
118        body,
119        "    ".repeat(indent)
120    )
121}
122
123/// Pretty-print an expression. Always emits parens around
124/// `BinOp` for round-trip safety.
125pub fn pp_expr(expr: &Expr) -> String {
126    match expr {
127        Expr::Ident(name, _) => name.clone(),
128        Expr::IntLit(v, _) => v.to_string(),
129        Expr::FloatLit(v, _) => format_float(*v),
130        Expr::StringLit(s, _) => format!("\"{}\"", escape_string(s)),
131        Expr::ArrayLit(elts, _) => {
132            let parts: Vec<String> = elts.iter().map(pp_expr).collect();
133            format!("[{}]", parts.join(", "))
134        }
135        Expr::Call(c) => pp_call(c),
136        Expr::BinOp(lhs, op, rhs) => {
137            format!("({} {} {})", pp_expr(lhs), pp_binop(*op), pp_expr(rhs))
138        }
139        Expr::UnaryNeg(e, _) => format!("(-{})", pp_expr(e)),
140        Expr::UnaryBitNot(e, _) => format!("(!{})", pp_expr(e)),
141        Expr::FieldAccess { source, field, .. } => format!("{source}.{field}"),
142        Expr::Cast(e, ty, _) => format!("({} as {})", pp_expr(e), ty.to_keyword()),
143        Expr::For(source) => format!("for {}", source.text),
144    }
145}
146
147fn pp_binding(b: &Binding) -> String {
148    let mut target = if b.targets.len() == 1 {
149        b.targets[0].clone()
150    } else {
151        format!("({})", b.targets.join(", "))
152    };
153    // `shared name: type := …` — cell type annotation
154    // (scope_model.md §"Type stability").
155    if let Some(ty) = &b.type_annotation {
156        target = format!("{target}: {ty}");
157    }
158    let prefix = pp_modifier_prefix(b.modifier);
159    if prefix.is_empty() {
160        format!("{} := {}", target, pp_expr(&b.value))
161    } else {
162        format!("{} {} := {}", prefix, target, pp_expr(&b.value))
163    }
164}
165
166fn pp_extern_port(p: &ExternPort) -> String {
167    if let Some(default) = &p.default {
168        format!("extern {}: {} = {}", p.name, p.typ, pp_expr(default))
169    } else {
170        format!("extern {}: {}", p.name, p.typ)
171    }
172}
173
174fn pp_cursor(c: &CursorDecl) -> String {
175    let mut out = format!("cursor {} = {}", c.name, pp_expr(&c.constructor));
176    if let Some(over) = &c.over {
177        out.push_str(" over ");
178        out.push_str(&pp_expr(over));
179    }
180    out
181}
182
183fn pp_module_def(m: &ModuleDef) -> String {
184    let params: Vec<String> = m
185        .params
186        .iter()
187        .map(|p| format!("{}: {}", p.name, p.typ))
188        .collect();
189    let outputs: Vec<String> = m
190        .outputs
191        .iter()
192        .map(|p| format!("{}: {}", p.name, p.typ))
193        .collect();
194    let mut body = String::new();
195    for s in &m.body {
196        body.push_str("    ");
197        body.push_str(&pp_statement(s));
198        body.push('\n');
199    }
200    format!(
201        "{}({}) -> ({}) := {{\n{}}}",
202        m.name,
203        params.join(", "),
204        outputs.join(", "),
205        body
206    )
207}
208
209fn pp_call(c: &CallExpr) -> String {
210    let args: Vec<String> = c.args.iter().map(pp_arg).collect();
211    format!("{}({})", c.func, args.join(", "))
212}
213
214fn pp_arg(arg: &Arg) -> String {
215    match arg {
216        Arg::Positional(e) => pp_expr(e),
217        Arg::Named(name, e) => format!("{}: {}", name, pp_expr(e)),
218    }
219}
220
221fn pp_modifier_prefix(m: BindingModifier) -> String {
222    let mut parts: Vec<&str> = Vec::new();
223    if m.has(WireModifier::Const) {
224        parts.push("const");
225    }
226    if m.has(WireModifier::Shared) {
227        parts.push("shared");
228    }
229    if m.has(WireModifier::Volatile) {
230        parts.push("volatile");
231    }
232    parts.join(" ")
233}
234
235fn pp_binop(op: BinOpKind) -> &'static str {
236    match op {
237        BinOpKind::Add => "+",
238        BinOpKind::Sub => "-",
239        BinOpKind::Mul => "*",
240        BinOpKind::Div => "/",
241        BinOpKind::Mod => "%",
242        BinOpKind::Pow => "**",
243        BinOpKind::BitAnd => "&",
244        BinOpKind::BitOr => "|",
245        BinOpKind::BitXor => "^",
246        BinOpKind::Shl => "<<",
247        BinOpKind::Shr => ">>",
248        BinOpKind::Eq => "==",
249        BinOpKind::Ne => "!=",
250        BinOpKind::Lt => "<",
251        BinOpKind::Gt => ">",
252        BinOpKind::Le => "<=",
253        BinOpKind::Ge => ">=",
254        BinOpKind::And => "&&",
255        BinOpKind::Or => "||",
256    }
257}
258
259fn escape_string(s: &str) -> String {
260    let mut out = String::with_capacity(s.len());
261    for c in s.chars() {
262        match c {
263            '\\' => out.push_str("\\\\"),
264            '"' => out.push_str("\\\""),
265            '\n' => out.push_str("\\n"),
266            '\t' => out.push_str("\\t"),
267            '\r' => out.push_str("\\r"),
268            c => out.push(c),
269        }
270    }
271    out
272}
273
274fn format_float(v: f64) -> String {
275    if v.is_finite() && v == v.trunc() && v.abs() < 1e18 {
276        format!("{v:.1}")
277    } else {
278        format!("{v}")
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{lexer, parser};
286
287    fn parse(src: &str) -> PolydatFile {
288        let tokens = lexer::lex(src).expect("lex");
289        parser::parse(tokens).expect("parse")
290    }
291
292    fn round_trip(src: &str) {
293        let ast1 = parse(src);
294        let printed = pp_file(&ast1);
295        let ast2 = parse(&printed);
296        let printed2 = pp_file(&ast2);
297        assert_eq!(
298            printed, printed2,
299            "second-pass print should be idempotent.\n\
300             original source:\n{src}\n\n\
301             first print:\n{printed}\n\n\
302             second print:\n{printed2}"
303        );
304    }
305
306    #[test]
307    fn round_trip_simple_const() {
308        round_trip("const x := 42\n");
309    }
310
311    #[test]
312    fn round_trip_string_const() {
313        round_trip("const dataset := \"sift1m\"\n");
314    }
315
316    #[test]
317    fn round_trip_init_binding() {
318        round_trip("const prebuffer := dataset_prebuffer(\"example\")\n");
319    }
320
321    #[test]
322    fn round_trip_function_call() {
323        round_trip("ratio := mod(cycle, 100)\n");
324    }
325
326    #[test]
327    fn round_trip_named_args() {
328        round_trip("v := dist_normal(mean: 72.0, stddev: 5.0)\n");
329    }
330
331    #[test]
332    fn round_trip_binop() {
333        round_trip("y := (x + 1)\n");
334    }
335
336    #[test]
337    fn round_trip_inputs() {
338        round_trip("input (cycle: u64, thread: u64)\n");
339    }
340
341    #[test]
342    fn round_trip_extern() {
343        round_trip("extern dataset: String\n");
344    }
345
346    #[test]
347    fn round_trip_tuple_destructure() {
348        round_trip("(a, b) := unpack(cycle)\n");
349    }
350
351    #[test]
352    fn round_trip_workload_typical() {
353        // Mirrors the shape of full_cql_vector workload bindings.
354        let src = "\
355const dataset := \"sift1m\"
356const prefix := \"vec_default\"
357profiles := matching_profiles(dataset, prefix)
358table := first(profiles)
359";
360        round_trip(src);
361    }
362
363    #[test]
364    fn round_trip_string_escapes() {
365        round_trip("const s := \"hello \\\"world\\\"\"\n");
366    }
367
368    #[test]
369    fn round_trip_array_literal() {
370        round_trip("const weights := [60.0, 20.0, 15.0, 5.0]\n");
371    }
372
373    #[test]
374    fn round_trip_cursor() {
375        round_trip("cursor users = range(0, 1000000)\n");
376    }
377
378    #[test]
379    fn round_trip_cursor_with_over() {
380        // The `over <expr>` partition clause (SRD-71) must survive
381        // projection — pp_cursor emits it so over-bearing cursors
382        // round-trip faithfully.
383        round_trip("cursor q = range(0, 100) over p\n");
384    }
385
386    #[test]
387    fn pp_cursor_emits_over_clause() {
388        let ast = parse("cursor q = range(0, 100) over p\n");
389        let printed = pp_file(&ast);
390        assert!(
391            printed.contains(" over p"),
392            "projected cursor must retain its `over` clause, got:\n{printed}"
393        );
394    }
395}