praxis-cli 0.2.0

The `praxis` command-line tool: run, check and debug Praxis programs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! The `praxis run` command: load a `.px` file and run the full pipeline
//! (parse → analyze → typed HIR → MIR → Cranelift JIT → execute) over its
//! top-level statements, which are the program (ADR-154).
//!
//! Exit codes are [`crate::exit_code`]'s closed set: `OK` when the program ran
//! to completion with no fault, `FAILED` for a language error (parse / type /
//! lowering) *or* a runtime fault (overflow / division by zero / …), `USAGE`
//! when a source or `--input` file cannot be read. `run::run` prints its own
//! diagnostics and never returns `Err` for a user-facing problem, so the code
//! it returns is the final one.

use std::path::Path;

use praxis_ast::AstNode;
use praxis_codegen_cranelift::Jit;
use praxis_hir::{TypedItem, analyze_root, lower, mono::monomorphize};
use praxis_mir::{annotate, lower_module, verify};
use praxis_runtime::{Runtime, RuntimeContext};
use praxis_source::diagnostic::sort_by_position;

use crate::breakpoint_host;
use crate::debug_mode::DebugMode;
use crate::{diagnostic_render, exit_code, source_file};

/// Run the `run` command against `file`. Returns the process exit code.
///
/// `input_file` optionally overrides the process input (§7.1): when `None`,
/// standard input is read **by the program's first `read`** and not before
/// (§7.10 — see [`lazy_stdin`]); when `Some(path)`, the file is read up front,
/// because a regular file cannot block and reporting an unreadable `--input`
/// before the program runs is worth more than the symmetry.
pub fn run(
    file: &str,
    input_file: Option<&str>,
    debug: DebugMode,
    color: crate::color_mode::ColorMode,
) -> anyhow::Result<i32> {
    let path = Path::new(file);
    let text = match source_file::read(file) {
        Ok(t) => t,
        Err(code) => return Ok(code),
    };

    let source = praxis_source::SourceMap::new();
    let id = source.intern(path, text.clone());

    // Front end: parse → resolve → infer.
    //
    // Spelled out here rather than through `praxis_lsp::query::Snapshot`, which
    // is where ADR-097 put this sequence and where `praxis check` reads it from:
    // `run` goes on to lower the tree, and `Snapshot::parse` is crate-private so
    // that a `SyntaxNode` never crosses the crate boundary (ADR-095). The order
    // is the shared one either way — `sort_by_position` is the same comparator
    // `Snapshot::diagnostics` sorts with.
    let parsed = praxis_parser::parse(id, &text);
    let mut diagnostics = parsed.diagnostics;
    let mut analysis = analyze_root(id, &parsed.tree);
    diagnostics.extend(analysis.diagnostics.clone());
    sort_by_position(&mut diagnostics);

    // Honesty gate: never JIT malformed input. If any language errors exist
    // (parse / type / lowering), report them and stop.
    let rendered = diagnostic_render::render_all(&source, &diagnostics, color.palette());
    if rendered.has_errors() {
        diagnostic_render::write_to(&mut std::io::stderr(), &rendered)?;
        return Ok(exit_code::FAILED);
    }

    // Lower to typed HIR, then MIR, then JIT. HIR lowering may emit its own
    // `Y1xx` diagnostics (e.g. generic-fn-not-supported); surface those too.
    let root = match praxis_ast::SourceFile::cast(parsed.tree.clone()) {
        Some(r) => r,
        None => {
            eprintln!("error: internal — parse tree root is not a SOURCE_FILE");
            return Ok(exit_code::FAILED);
        }
    };
    let module = lower(id, &root, &mut analysis);
    if !module.diagnostics.is_empty() {
        let mut all = module.diagnostics.clone();
        sort_by_position(&mut all);
        let rendered = diagnostic_render::render_all(&source, &all, color.palette());
        diagnostic_render::write_to(&mut std::io::stderr(), &rendered)?;
        return Ok(exit_code::FAILED);
    }

    // Which function the host calls, read before monomorphization consumes the
    // module.
    //
    // A file's top-level statements are its program (ADR-154), and `<entry>` is
    // the whole answer: a file with none has nothing to run and there is no
    // second spelling to fall back to.
    //
    // Asked here rather than after the JIT, because "nothing to run" is
    // knowable from the module and compiling declarations nobody is going to
    // call buys the report nothing.
    let Some(entry_name) = praxis_hir::entry_point(|name| {
        module
            .items
            .iter()
            .any(|item| matches!(item, TypedItem::Fn(f) if f.name == name))
    }) else {
        eprintln!("error: no statements to run");
        // A file whose whole program sits inside a `fn main` is the one way to
        // get here worth naming: it is the shape other languages ask for, it
        // type-checks, and the fix is one line either way.
        if module
            .items
            .iter()
            .any(|item| matches!(item, TypedItem::Fn(f) if f.name == "main"))
        {
            eprintln!(
                "note: this file declares `fn main`, but a Praxis program is its \
                 top-level statements — call it with `main()`, or move its body \
                 to the top level"
            );
        }
        return Ok(exit_code::FAILED);
    };

    // Monomorphization (§13.6): instantiate every polymorphic callee per
    // call site, between typed HIR and MIR. Produces a module of monomorphic
    // fns (one clone per generic callee + concrete type args); the MIR builder
    // then runs unchanged on it.
    let module = monomorphize(module, &analysis.names, &mut analysis.db);

    let mut funcs = lower_module(&module, &mut analysis.db);
    for f in &mut funcs {
        annotate(f);
        // A failure here is a compiler bug, never a program error, so it is
        // reported as one and no code is generated from it.
        if let Err(errs) = verify(f) {
            eprintln!("internal error: {}", praxis_mir::verify::report(&errs));
            return Ok(exit_code::FAILED);
        }
    }

    let mut jit = match Jit::new() {
        Ok(j) => j,
        Err(e) => {
            eprintln!("error: could not initialize the JIT: {e}");
            return Ok(exit_code::FAILED);
        }
    };
    let ids = match jit.compile(&funcs, &mut analysis.db) {
        Ok(ids) => ids,
        Err(e) => {
            eprintln!("error: JIT compilation failed: {e}");
            return Ok(exit_code::FAILED);
        }
    };

    // The entry point was found in the module above and every item of that
    // module is compiled, so a miss here is a compiler bug rather than a
    // program error — reported as one, like the MIR verifier's above.
    let Some(entry_id) = ids.get(entry_name).copied() else {
        eprintln!("internal error: the entry point `{entry_name}` was not compiled");
        return Ok(exit_code::FAILED);
    };

    // Execute. The entry point takes no GcRef params beyond the hidden context.
    let mut runtime = Runtime::new();
    let mut ctx = runtime.context();

    // The process input (§7.10). An I/O failure is reported, never laundered
    // into empty input: a program that reads a missing `--input` file would
    // otherwise "succeed" against input the user never supplied, and a
    // truncated read would silently produce a wrong answer. Same exit code
    // (`exit_code::USAGE`) as an unreadable source file.
    //
    // `--input FILE` is read here, before the program runs. A regular file
    // cannot block, and an unreadable one is worth reporting before any output
    // is printed.
    //
    // **Standard input is not.** §7.10: "The first `read` lazily reads standard
    // input once into an immutable GC-managed source buffer." Reading it here
    // would consume stdin for a program with no `read` in it, and against an
    // open pipe — a terminal, a CI harness holding the descriptor — `praxis
    // run` would block forever waiting for an EOF nobody is going to send. The
    // reader below is installed, not called; `praxis_get_input` calls it the
    // one time, from the program's first `read`.
    //
    // The `Text` is installed unconditionally, a zero-byte file included: empty
    // input is input, and the rule and its reasons are stated once, at
    // `praxis_get_input` (ADR-087). The CLI's own decision is only the one
    // above — `--input` is eager, standard input is lazy.
    match input_file {
        Some(path) => match std::fs::read_to_string(path) {
            Ok(t) => {
                let input_ref = runtime.alloc_text(&t);
                ctx.input_source = input_ref;
                lazy_stdin::record(t);
            }
            Err(err) => {
                eprintln!("error: failed to read input file `{path}`: {err}");
                return Ok(exit_code::USAGE);
            }
        },
        None => praxis_runtime::install_input_reader(lazy_stdin::read),
    }

    // Arm the `:bp` stops (§9.8). This has to happen before the call below and
    // not inside it: the handler is reached from generated code, several native
    // frames under this one, so what it renders with is state it finds rather
    // than state it is passed. `analysis.db` is the database codegen just
    // compiled against, which is what makes a local's positional `type_id` mean
    // anything.
    //
    // A program with no marker in it never calls the wrapper, so this costs a
    // clone of the type database and nothing else.
    let source_name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_default();
    breakpoint_host::install(&analysis.db, &text, &source_name, debug, color);

    // SAFETY: `entry` was just finalized for `entry_id`; the JIT outlives the
    // call. The entry point declares no parameters, so it takes the context and
    // nothing else — the shape `abi_signature` emitted for it.
    let entry: praxis_debugger::session::EntryPoint =
        unsafe { std::mem::transmute(jit.entry(entry_id)) };
    // The entry point is `Unit` (ADR-067 decision 3), so what comes back is
    // dropped: a file is not an expression and has no answer to print. That is
    // also what keeps `out(…)` at the top level from printing twice.
    let _ = unsafe { entry(&mut ctx as *mut RuntimeContext) };

    // The run is over, so no later stop has a program to stop. Disarming here
    // rather than only on the fault path means the crash debugger's `restart`
    // (§9.7) cannot fire a handler into the terminal the debugger is holding.
    breakpoint_host::disarm();

    if runtime.has_pending_fault() {
        let kind = runtime.fault();
        // The message a `panic`/`assert` carried (§9.1). Copied out now: the
        // interactive path moves `runtime` into the `DebugSession` below, and
        // the message has to survive that move to be rendered.
        let message = runtime.fault_message().map(str::to_string);
        // Decide whether to enter the interactive crash REPL (§9.4, §9.6).
        // `always` or TTY `auto` enters the REPL; `never` or non-TTY `auto`
        // prints the noninteractive diagnostic and exits nonzero.
        if debug.wants_repl() {
            // Take the snapshot out of the runtime; the REPL owns it for its
            // lifetime. A missing snapshot (host-side fault before any debug
            // frame) degrades to the noninteractive render.
            if let Some(snapshot) = runtime.take_crash_snapshot() {
                // Show the fault line + §7.11 detail before the prompt, so the
                // user sees what happened (§9.4's banner). Enrich the locals
                // render with the live TypeDb + source text so temps show their
                // type and materializing expression.
                let ctx = praxis_debugger::render::RenderCtx::new(&analysis.db, &text);
                praxis_debugger::render::render_noninteractive(
                    &mut std::io::stderr(),
                    kind,
                    message.as_deref(),
                    Some(&snapshot),
                    Some(runtime.parse_detail()),
                    color.palette(),
                    &ctx,
                )?;
                // Hand the live compile/run state to the REPL as a
                // `DebugSession`, so `p EXPR`/`source`/`restart`/`reload` can
                // reach the Jit/Runtime/TypeDb/source/input. The snapshot was
                // taken out of `runtime` above, so the two are decoupled.
                // SAFETY: `entry_fn` was just transmuted from a finalized
                // JIT entry for `entry_id`; the `jit` outlives the REPL (it
                // moves into the session and is dropped with it).
                let session = praxis_debugger::session::DebugSession {
                    jit,
                    entry_fn: entry,
                    runtime,
                    analysis,
                    source_text: text.clone(),
                    source_path: path.to_path_buf(),
                    // What the program actually read — empty if it never
                    // evaluated a `read`. The session re-installs it directly on
                    // each re-run, which is §9.7's guarantee that a restart sees
                    // the same input; `clear_input_reader` below is what stops a
                    // second read of an exhausted stdin.
                    input_text: lazy_stdin::text(),
                    eval_generation: std::rc::Rc::new(praxis_codegen_cranelift::Generation::new()),
                };
                // The session owns the input from here: every re-run
                // installs `input_text` directly (§9.7 — a restart must see
                // the same input), so the reader must not fire again against a
                // stdin that is now at EOF.
                praxis_runtime::clear_input_reader();
                let mut repl = praxis_debugger::repl::Repl::new_session(snapshot, session);
                // The full-screen debugger when there is a terminal to take over,
                // the line REPL otherwise. `--debug=always` in a script and a
                // piped command list both land in the second branch, and must:
                // the TUI needs keystrokes to read and a screen to draw on, and
                // with neither it would show a frozen screen against EOF.
                //
                // The noninteractive report above has already been written to
                // stderr, i.e. to the *primary* screen. The TUI draws on the
                // alternate screen, so quitting it restores that report — the
                // crash stays in the scrollback instead of vanishing with the UI.
                if praxis_debugger::tui::should_use_tui() {
                    let stop = praxis_debugger::tui::Stop::Fault(kind, message.clone());
                    let tui = praxis_debugger::tui::Tui::new(repl, stop);
                    (repl, _) = praxis_debugger::tui::run(tui)?;
                } else {
                    let stdin = std::io::stdin();
                    let mut stdin = stdin.lock();
                    let stderr = std::io::stderr();
                    let mut stderr = stderr.lock();
                    let _ = repl.run(&mut stdin, &mut stderr);
                }
                // Drop the snapshot, then the heap, then the JIT generations
                // its objects pointed into (F13, H15). `teardown` is what makes
                // that order a compile-time obligation.
                if let Some(session) = repl.into_session() {
                    session.teardown();
                }
            } else {
                let ctx = praxis_debugger::render::RenderCtx::new(&analysis.db, &text);
                praxis_debugger::render::render_noninteractive(
                    &mut std::io::stderr(),
                    kind,
                    message.as_deref(),
                    None,
                    Some(runtime.parse_detail()),
                    color.palette(),
                    &ctx,
                )?;
                jit.retire(runtime.teardown());
            }
        } else {
            let ctx = praxis_debugger::render::RenderCtx::new(&analysis.db, &text);
            praxis_debugger::render::render_noninteractive(
                &mut std::io::stderr(),
                kind,
                message.as_deref(),
                runtime.crash_snapshot(),
                Some(runtime.parse_detail()),
                color.palette(),
                &ctx,
            )?;
            jit.retire(runtime.teardown());
        }
        return Ok(exit_code::FAILED);
    }

    // The run is over: drop the heap, then reclaim the arenas its objects
    // pointed into — the JIT generation (F13) and the parser plans.
    // `Runtime::teardown` mints the proof both demand, so this cannot be
    // written the other way round (hazard H15).
    let proof = runtime.teardown();
    praxis_runtime::retire_parser_plans(&proof);
    jit.retire(proof);
    Ok(exit_code::OK)
}

/// Standard input, read by the program's **first** `read` and not before
/// (§7.10).
///
/// The runtime takes a plain `fn` — it is stored across the ABI boundary and
/// called from generated code's stack, so it carries no captured state — which
/// is why the source and the result live in thread-locals here rather than in a
/// closure. The runtime is single-threaded (§12.1) and `praxis run` runs one
/// program per process, so there is one of each.
///
/// [`record`] exists for the `--input FILE` path, which is still read up front:
/// the crash debugger's session needs the text the program actually saw, and it
/// needs it from one place regardless of where the input came from.
mod lazy_stdin {
    use std::cell::RefCell;

    thread_local! {
        /// What the program read, for the crash debugger's re-runs (§9.7).
        /// Empty until something reads, which for a `read`-free program is
        /// never — and empty is then the truth.
        static TEXT: RefCell<String> = const { RefCell::new(String::new()) };
    }

    /// The input the program has seen so far.
    pub(super) fn text() -> String {
        TEXT.with(|slot| slot.borrow().clone())
    }

    /// Record an input the host read itself (the `--input FILE` path).
    pub(super) fn record(input: String) {
        TEXT.with(|slot| *slot.borrow_mut() = input);
    }

    /// Read standard input to EOF, once. Installed as the runtime's
    /// [`praxis_runtime::InputReader`]; the runtime calls it from the first
    /// `read` a program evaluates, and never otherwise.
    ///
    /// A terminal stdin reads as empty rather than blocking on a human who was
    /// not asked for anything.
    ///
    /// An I/O failure exits the process with [`crate::exit_code::USAGE`]. It
    /// cannot be returned instead: the runtime's reader is infallible by
    /// design, because what an unreadable stdin *means* is the host's question.
    /// Laundering it into empty input is the one thing that would be wrong — a
    /// truncated read would silently produce a wrong answer.
    pub(super) fn read() -> Vec<u8> {
        use std::io::IsTerminal;
        if std::io::stdin().is_terminal() {
            return Vec::new();
        }
        match std::io::read_to_string(std::io::stdin()) {
            Ok(t) => {
                let bytes = t.as_bytes().to_vec();
                record(t);
                bytes
            }
            Err(err) => {
                eprintln!("error: failed to read input from stdin: {err}");
                std::process::exit(crate::exit_code::USAGE);
            }
        }
    }
}