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