4.3 KiB
| title | status | type | priority | created_at | updated_at |
|---|---|---|---|---|---|
| §16 The Compilation Pipeline | completed | task | normal | 2026-03-10T23:30:01Z | 2026-03-10T23:30:01Z |
§16 The Compilation Pipeline — Stub to fill
File: edu/src/lisp-compiler.md, section ### 16. The Compilation Pipeline
Replace the stub line with full content. Target 600–800 words. Wire all stages into a working CLI binary. Trace the complete factorial example end-to-end.
Learning objectives
- Implement the
compilefunction that chains parse → analyse → generate - Write a CLI
main.rsthat reads from a file or stdin and writes to stdout - Handle and display errors from any stage
- Demonstrate the complete workflow:
.lisp→.c→ compile → run
Content to write
The compile function
In src/main.rs (or a src/lib.rs):
pub fn compile(source: &str) -> Result<String, error::CompileError> {
let exprs = parser::parse(source)?;
let exprs = analyser::analyse(exprs)?;
let c = codegen::generate(exprs);
Ok(c)
}
Handling top-level non-define expressions
The code generator in §14 skipped top-level expressions that are not define forms (e.g., (display (factorial 10))). These must be emitted inside a C main function. Complete the generate function:
pub fn generate(exprs: Vec<Expr>) -> String {
let mut out = String::new();
out.push_str(PREAMBLE);
// Forward declarations
for expr in &exprs {
if let Expr::Define { name, value } = expr {
out.push_str(&gen_forward_decl(name, value));
}
}
out.push('\n');
// Function and variable definitions
for expr in &exprs {
if let Expr::Define { name, value } = expr {
match value.as_ref() {
Expr::Lambda { params, body } =>
out.push_str(&gen_function_def(name, params, body)),
_ =>
out.push_str(&gen_variable_def(name, value)),
}
}
}
// main(): emit top-level non-define expressions
out.push_str("\nint main(void) {\n");
for expr in &exprs {
if !matches!(expr, Expr::Define { .. }) {
out.push_str(&format!(" {};\n", gen_stmt(expr)));
}
}
out.push_str(" return 0;\n}\n");
out
}
The CLI: src/main.rs
use std::{env, fs, io::{self, Read}, process};
fn main() {
let args: Vec<String> = env::args().collect();
let source = match args.get(1) {
Some(path) => fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("error reading {}: {}", path, e);
process::exit(1);
}),
None => {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf).unwrap();
buf
}
};
match compile(&source) {
Ok(c_source) => print!("{}", c_source),
Err(e) => {
eprintln!("{}", e);
process::exit(1);
}
}
}
Explain: if a file path is given as argv[1], read from it; otherwise read from stdin. Always write C to stdout. This allows minilisp factorial.lisp > factorial.c.
The end-to-end workflow
Walk through the complete factorial example step by step:
# 1. Write a MiniLisp program
cat > factorial.lisp <<'EOF'
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
(display (factorial 10))
(newline)
EOF
# 2. Compile to C
cargo run -- factorial.lisp > factorial.c
# 3. Compile the C
cc -o factorial factorial.c
# 4. Run
./factorial
# Output: 3628800
Show the generated factorial.c in full so the reader can verify the output looks correct.
Error handling demo
Show what happens when the compiler rejects invalid input:
echo "(define (f x) (g x))" | cargo run # g is undefined
# stderr: semantic error: undefined symbol: `g`
# exit code: 1
Build and validate
cargo fmt && cargo check && cargo clippy && cargo test
All should pass. This is the project's first fully working state.
Style notes
- The end-to-end workflow walkthrough is the climax of the implementation sections — give it space
- Show the generated C in full; readers deserve to see the fruit of their work
- The error demo is quick but important — confirm the pipeline fails gracefully
- End with a note of congratulation: the reader has just built a compiler