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