Skip to main content

kaish_kernel/ast/
sexpr.rs

1//! S-expression formatter for kaish AST.
2//!
3//! Converts AST nodes to the S-expression format used in test snapshots.
4//! S-expressions provide a stable, readable format that's easier to diff
5//! than Debug output.
6
7use super::*;
8
9/// Format a Program as an S-expression.
10/// For single-statement programs, formats just the statement.
11/// For multi-statement programs, formats as a sequence.
12pub fn format_program(program: &Program) -> String {
13    let stmts: Vec<_> = program
14        .statements
15        .iter()
16        .filter(|s| !matches!(s, Stmt::Empty))
17        .collect();
18
19    match stmts.len() {
20        0 => "(program)".to_string(),
21        1 => format_stmt(stmts[0]),
22        _ => {
23            let parts: Vec<String> = stmts.iter().map(|s| format_stmt(s)).collect();
24            format!("(program {})", parts.join(" "))
25        }
26    }
27}
28
29/// Format a statement as an S-expression.
30pub fn format_stmt(stmt: &Stmt) -> String {
31    match stmt {
32        Stmt::Assignment(a) => format_assignment(a),
33        Stmt::Command(cmd) => format_command(cmd),
34        Stmt::Pipeline(p) => format_pipeline(p),
35        Stmt::If(if_stmt) => format_if(if_stmt),
36        Stmt::For(for_loop) => format_for(for_loop),
37        Stmt::While(while_loop) => format_while(while_loop),
38        Stmt::Case(case_stmt) => format_case(case_stmt),
39        Stmt::Break(n) => match n {
40            Some(level) => format!("(break {})", level),
41            None => "(break)".to_string(),
42        },
43        Stmt::Continue(n) => match n {
44            Some(level) => format!("(continue {})", level),
45            None => "(continue)".to_string(),
46        },
47        Stmt::Return(expr) => match expr {
48            Some(e) => format!("(return {})", format_expr(e)),
49            None => "(return)".to_string(),
50        },
51        Stmt::Exit(expr) => match expr {
52            Some(e) => format!("(exit {})", format_expr(e)),
53            None => "(exit)".to_string(),
54        },
55        Stmt::ToolDef(tool) => format_tooldef(tool),
56        Stmt::Test(test_expr) => format!("(test {})", format_test_expr(test_expr)),
57        Stmt::Arith(expr_str) => format!("(arith \"{}\")", expr_str),
58        Stmt::AndChain { left, right } => {
59            format!("(and-chain {} {})", format_stmt(left), format_stmt(right))
60        }
61        Stmt::OrChain { left, right } => {
62            format!("(or-chain {} {})", format_stmt(left), format_stmt(right))
63        }
64        Stmt::EnvScoped { assignments, body } => {
65            let assigns: Vec<String> = assignments.iter().map(format_assignment).collect();
66            format!("(env-scoped ({}) {})", assigns.join(" "), format_stmt(body))
67        }
68        Stmt::Empty => "(empty)".to_string(),
69    }
70}
71
72/// Format an assignment as an S-expression.
73fn format_assignment(a: &Assignment) -> String {
74    let value = format_expr(&a.value);
75    format!("(assign {} {} local={})", format_varpath(&a.path), value, a.local)
76}
77
78/// Format a command as an S-expression.
79pub fn format_command(cmd: &Command) -> String {
80    let mut parts = vec![format!("(cmd {}", cmd.name)];
81
82    for arg in &cmd.args {
83        parts.push(format_arg(arg));
84    }
85
86    for redir in &cmd.redirects {
87        parts.push(format_redirect(redir));
88    }
89
90    format!("{})", parts.join(" "))
91}
92
93/// Format an argument as an S-expression.
94fn format_arg(arg: &Arg) -> String {
95    match arg {
96        Arg::Positional(expr) => format!("(pos {})", format_expr(expr)),
97        Arg::Named { key, value } => format!("(named {} {})", key, format_expr(value)),
98        Arg::WordAssign { key, value } => format!("(wordassign {} {})", key, format_expr(value)),
99        Arg::ShortFlag(f) => format!("(shortflag {})", f),
100        Arg::LongFlag(f) => format!("(longflag {})", f),
101        Arg::DoubleDash => "(doubledash)".to_string(),
102    }
103}
104
105/// Format a redirect as an S-expression.
106fn format_redirect(redir: &Redirect) -> String {
107    let kind = match redir.kind {
108        RedirectKind::StdoutOverwrite => ">",
109        RedirectKind::StdoutAppend => ">>",
110        RedirectKind::Stdin => "<",
111        RedirectKind::HereDoc(_) => "<<",
112        RedirectKind::HereString => "<<<",
113        RedirectKind::Stderr => "2>",
114        RedirectKind::Both => "&>",
115        RedirectKind::MergeStderr => "2>&1",
116        RedirectKind::MergeStdout => "1>&2",
117    };
118    format!("(redir {} {})", kind, format_expr(&redir.target))
119}
120
121/// Format a pipeline as an S-expression.
122/// Format a command-substitution body — a block of statements — as an s-expr.
123/// A one-statement block formats as that statement; multiple are wrapped in a
124/// `(block …)` so the sequence is visible in snapshots.
125pub fn format_stmt_block(stmts: &[Stmt]) -> String {
126    if stmts.len() == 1 {
127        format_stmt(&stmts[0])
128    } else {
129        let inner: Vec<String> = stmts.iter().map(format_stmt).collect();
130        format!("(block {})", inner.join(" "))
131    }
132}
133
134pub fn format_pipeline(p: &Pipeline) -> String {
135    let cmds: Vec<String> = p
136        .stages
137        .iter()
138        .map(|stage| match stage {
139            PipelineStage::Command(cmd) => format_command(cmd),
140            PipelineStage::Compound(stmt) => format_stmt(stmt),
141        })
142        .collect();
143
144    if p.background {
145        if cmds.len() == 1 {
146            format!("(background {})", cmds[0])
147        } else {
148            format!("(background (pipeline {}))", cmds.join(" "))
149        }
150    } else {
151        format!("(pipeline {})", cmds.join(" "))
152    }
153}
154
155/// Format an if statement as an S-expression.
156fn format_if(if_stmt: &IfStmt) -> String {
157    let cond = format_expr(&if_stmt.condition);
158    let then_stmts: Vec<String> = if_stmt
159        .then_branch
160        .iter()
161        .filter(|s| !matches!(s, Stmt::Empty))
162        .map(format_stmt)
163        .collect();
164    let then_part = format!("(then {})", then_stmts.join(" "));
165
166    match &if_stmt.else_branch {
167        Some(else_stmts) => {
168            let else_inner: Vec<String> = else_stmts
169                .iter()
170                .filter(|s| !matches!(s, Stmt::Empty))
171                .map(format_stmt)
172                .collect();
173            if else_inner.is_empty() {
174                format!("(if {} {} (else))", cond, then_part)
175            } else {
176                format!("(if {} {} (else {}))", cond, then_part, else_inner.join(" "))
177            }
178        }
179        None => format!("(if {} {} (else))", cond, then_part),
180    }
181}
182
183/// Format a for loop as an S-expression.
184fn format_for(for_loop: &ForLoop) -> String {
185    let items: Vec<String> = for_loop.items.iter().map(format_expr).collect();
186    let body_stmts: Vec<String> = for_loop
187        .body
188        .iter()
189        .filter(|s| !matches!(s, Stmt::Empty))
190        .map(format_stmt)
191        .collect();
192    format!(
193        "(for {} (in {}) (do {}))",
194        for_loop.variable,
195        items.join(" "),
196        body_stmts.join(" ")
197    )
198}
199
200/// Format a while loop as an S-expression.
201fn format_while(while_loop: &WhileLoop) -> String {
202    let cond = format_expr(&while_loop.condition);
203    let body_stmts: Vec<String> = while_loop
204        .body
205        .iter()
206        .filter(|s| !matches!(s, Stmt::Empty))
207        .map(format_stmt)
208        .collect();
209    format!("(while {} (do {}))", cond, body_stmts.join(" "))
210}
211
212/// Format a case statement as an S-expression.
213fn format_case(case_stmt: &CaseStmt) -> String {
214    let expr = format_expr(&case_stmt.expr);
215    let branches: Vec<String> = case_stmt
216        .branches
217        .iter()
218        .map(format_case_branch)
219        .collect();
220    format!("(case {} ({}))", expr, branches.join(" "))
221}
222
223/// Format a case branch as an S-expression.
224fn format_case_branch(branch: &CaseBranch) -> String {
225    let patterns = branch.patterns.join("|");
226    let body_stmts: Vec<String> = branch
227        .body
228        .iter()
229        .filter(|s| !matches!(s, Stmt::Empty))
230        .map(format_stmt)
231        .collect();
232    format!("(branch \"{}\" ({}))", patterns, body_stmts.join(" "))
233}
234
235/// Format a tool definition as an S-expression.
236fn format_tooldef(tool: &ToolDef) -> String {
237    let params: Vec<String> = tool.params.iter().map(format_param).collect();
238    let body_stmts: Vec<String> = tool
239        .body
240        .iter()
241        .filter(|s| !matches!(s, Stmt::Empty))
242        .map(format_stmt)
243        .collect();
244    format!(
245        "(tooldef {} ({}) ({}))",
246        tool.name,
247        params.join(" "),
248        body_stmts.join(" ")
249    )
250}
251
252/// Format a parameter definition as an S-expression.
253fn format_param(param: &ParamDef) -> String {
254    let type_str = param
255        .param_type
256        .as_ref()
257        .map(|t| match t {
258            ParamType::String => "string",
259            ParamType::Int => "int",
260            ParamType::Float => "float",
261            ParamType::Bool => "bool",
262        })
263        .unwrap_or("any");
264
265    match &param.default {
266        Some(default) => format!("(param {} {} {})", param.name, type_str, format_expr(default)),
267        None => format!("(param {} {})", param.name, type_str),
268    }
269}
270
271/// Format an expression as an S-expression.
272pub fn format_expr(expr: &Expr) -> String {
273    match expr {
274        Expr::Not(inner) => format!("(not {})", format_expr(inner)),
275        Expr::Literal(value) => format_value(value),
276        Expr::NumericLiteral { value, raw } => {
277            format!("(numeric-literal {} raw={:?})", format_value(value), raw)
278        }
279        Expr::VarRef(path) => format!("(varref {})", format_varpath(path)),
280        Expr::Interpolated(parts) => {
281            let parts_str: Vec<String> = parts
282                .iter()
283                .map(format_string_part)
284                .collect();
285            format!("(interpolated {})", parts_str.join(" "))
286        }
287        Expr::HereDocBody { parts, strip_tabs } => {
288            let parts_str: Vec<String> = parts
289                .iter()
290                .map(|sp| format_string_part(&sp.part))
291                .collect();
292            format!(
293                "(heredoc-body strip-tabs={} {})",
294                strip_tabs,
295                parts_str.join(" ")
296            )
297        }
298        Expr::BinaryOp { left, op, right } => {
299            let op_str = match op {
300                BinaryOp::And => "and",
301                BinaryOp::Or => "or",
302            };
303            format!("({} {} {})", op_str, format_expr(left), format_expr(right))
304        }
305        Expr::CommandSubst(stmts) => {
306            format!("(cmdsubst {})", format_stmt_block(stmts))
307        }
308        Expr::Test(test_expr) => format!("(test {})", format_test_expr(test_expr)),
309        Expr::Positional(n) => format!("(positional {})", n),
310        Expr::AllArgs => "(all-args)".to_string(),
311        Expr::ArgCount => "(arg-count)".to_string(),
312        Expr::VarLength(path) => format!("(var-length {})", format_varpath(path)),
313        Expr::VarWithDefault { path, default } => {
314            let default_parts: Vec<String> = default.iter().map(format_string_part).collect();
315            format!("(var-default {} ({}))", format_varpath(path), default_parts.join(" "))
316        }
317        Expr::Arithmetic(expr_str) => format!("(arithmetic \"{}\")", expr_str),
318        Expr::Arith(expr_str) => format!("(arith \"{}\")", expr_str),
319        Expr::Command(cmd) => format_command(cmd),
320        Expr::LastExitCode => "(last-exit-code)".to_string(),
321        Expr::CurrentPid => "(current-pid)".to_string(),
322        Expr::GlobPattern(s) => format!("(glob \"{}\")", s),
323        Expr::ListLiteral(elems) => {
324            let parts: Vec<String> = elems
325                .iter()
326                .map(|elem| match elem {
327                    ListElem::Item(e) => format_expr(e),
328                    ListElem::Spread(e) => format!("(spread {})", format_expr(e)),
329                })
330                .collect();
331            format!("(list {})", parts.join(" "))
332        }
333        Expr::RecordLiteral(entries) => {
334            let parts: Vec<String> = entries
335                .iter()
336                .map(|entry| {
337                    let key = match &entry.key {
338                        RecordKey::Bare(s) => s.clone(),
339                        RecordKey::Quoted(s) => format!("\"{}\"", s),
340                        RecordKey::Interpolated(parts) => {
341                            let parts_str: Vec<String> =
342                                parts.iter().map(format_string_part).collect();
343                            format!("(interpolated {})", parts_str.join(" "))
344                        }
345                    };
346                    format!("({} {})", key, format_expr(&entry.value))
347                })
348                .collect();
349            format!("(record {})", parts.join(" "))
350        }
351    }
352}
353
354/// Format a test expression as an S-expression.
355pub fn format_test_expr(test: &TestExpr) -> String {
356    match test {
357        TestExpr::FileTest { op, path } => {
358            let op_str = match op {
359                FileTestOp::Exists => "-e",
360                FileTestOp::IsFile => "-f",
361                FileTestOp::IsDir => "-d",
362                FileTestOp::Readable => "-r",
363                FileTestOp::Writable => "-w",
364                FileTestOp::Executable => "-x",
365                FileTestOp::IsSymlink => "-L",
366            };
367            format!("(file {} {})", op_str, format_expr(path))
368        }
369        TestExpr::StringTest { op, value } => {
370            let op_str = match op {
371                StringTestOp::IsEmpty => "-z",
372                StringTestOp::IsNonEmpty => "-n",
373                StringTestOp::IsList => "-list",
374                StringTestOp::IsRecord => "-record",
375            };
376            format!("(string {} {})", op_str, format_expr(value))
377        }
378        TestExpr::Comparison { left, op, right } => {
379            let op_str = match op {
380                TestCmpOp::Eq => "==",
381                TestCmpOp::NotEq => "!=",
382                TestCmpOp::Match => "=~",
383                TestCmpOp::NotMatch => "!~",
384                TestCmpOp::Gt => ">",
385                TestCmpOp::Lt => "<",
386                TestCmpOp::GtEq => ">=",
387                TestCmpOp::LtEq => "<=",
388                TestCmpOp::NumEq => "-eq",
389                TestCmpOp::NumNotEq => "-ne",
390                TestCmpOp::NumGt => "-gt",
391                TestCmpOp::NumLt => "-lt",
392                TestCmpOp::NumGtEq => "-ge",
393                TestCmpOp::NumLtEq => "-le",
394            };
395            format!(
396                "(cmp {} {} {})",
397                op_str,
398                format_expr(left),
399                format_expr(right)
400            )
401        }
402        TestExpr::And { left, right } => {
403            format!("(and {} {})", format_test_expr(left), format_test_expr(right))
404        }
405        TestExpr::Or { left, right } => {
406            format!("(or {} {})", format_test_expr(left), format_test_expr(right))
407        }
408        TestExpr::Not { expr } => {
409            format!("(not {})", format_test_expr(expr))
410        }
411        TestExpr::In { left, right } => {
412            format!("(in {} {})", format_expr(left), format_expr(right))
413        }
414        TestExpr::NotIn { left, right } => {
415            format!("(not-in {} {})", format_expr(left), format_expr(right))
416        }
417    }
418}
419
420/// Format a StringPart as an S-expression.
421fn format_string_part(part: &StringPart) -> String {
422    match part {
423        StringPart::Literal(s) => format!("\"{}\"", escape_for_display(s)),
424        StringPart::Var(path) => format!("(varref {})", format_varpath(path)),
425        StringPart::VarWithDefault { path, default } => {
426            let default_parts: Vec<String> = default.iter().map(format_string_part).collect();
427            format!("(vardefault {} ({}))", format_varpath(path), default_parts.join(" "))
428        }
429        StringPart::VarLength(path) => format!("(varlength {})", format_varpath(path)),
430        StringPart::Positional(n) => format!("(positional {})", n),
431        StringPart::AllArgs => "(allargs)".to_string(),
432        StringPart::ArgCount => "(argcount)".to_string(),
433        StringPart::Arithmetic(expr) => format!("(arith \"{}\")", expr),
434        StringPart::CommandSubst(stmts) => format!("(cmdsubst {})", format_stmt_block(stmts)),
435        StringPart::LastExitCode => "(last-exit-code)".to_string(),
436        StringPart::CurrentPid => "(current-pid)".to_string(),
437    }
438}
439
440/// Escape control characters for display in test output.
441fn escape_for_display(s: &str) -> String {
442    s.replace('\n', "\\n")
443        .replace('\t', "\\t")
444        .replace('\r', "\\r")
445}
446
447/// Format a value as an S-expression.
448pub fn format_value(value: &Value) -> String {
449    match value {
450        Value::Null => "(null)".to_string(),
451        Value::Bool(b) => format!("(bool {})", b),
452        Value::Int(n) => format!("(int {})", n),
453        Value::Float(f) => format!("(float {})", f),
454        Value::String(s) => format!("(string \"{}\")", escape_for_display(s)),
455        Value::Json(json) => format!("(json {})", json),
456        Value::Bytes(b) => format!("(bytes len={})", b.len()),
457    }
458}
459
460/// Format a variable path as an S-expression.
461pub fn format_varpath(path: &VarPath) -> String {
462    path.segments
463        .iter()
464        .map(|seg| match seg {
465            VarSegment::Field(name) => name.clone(),
466            VarSegment::Index(i) => format!("[{i}]"),
467            VarSegment::Key(k) => format!("[{k}]"),
468            VarSegment::Dynamic(v) => format!("[${v}]"),
469            VarSegment::Slice(a, b) => format!(
470                "[{}:{}]",
471                a.map(|n| n.to_string()).unwrap_or_default(),
472                b.map(|n| n.to_string()).unwrap_or_default()
473            ),
474        })
475        .collect::<Vec<_>>()
476        .join(".")
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn format_simple_int() {
485        assert_eq!(format_value(&Value::Int(42)), "(int 42)");
486    }
487
488    #[test]
489    fn format_simple_string() {
490        assert_eq!(format_value(&Value::String("hello".to_string())), "(string \"hello\")");
491    }
492
493    #[test]
494    fn format_varpath_simple() {
495        let path = VarPath::simple("X");
496        assert_eq!(format_varpath(&path), "X");
497    }
498
499    #[test]
500    fn format_varpath_nested() {
501        let path = VarPath {
502            segments: vec![
503                VarSegment::Field("VAR".to_string()),
504                VarSegment::Field("field".to_string()),
505            ],
506        };
507        assert_eq!(format_varpath(&path), "VAR.field");
508    }
509}