bop-cli 0.3.0

CLI and REPL for the Bop programming language
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
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
//! `bop` / `bop repl` — interactive REPL.
//!
//! Stateful across inputs: each submission runs against a
//! `bop::ReplSession` that carries `let` / `fn` / struct / enum
//! / method / `use` state forward. Typing `let x = 5` then
//! `print(x)` on separate lines sees the same `x`, the way any
//! useful REPL works.
//!
//! Backed by rustyline for arrow-key history, emacs-style
//! editing, persisted history, multi-line input via the
//! `Validator` hook, and tab completion driven by the
//! `Completer` hook.
//!
//! **Multi-line input** works by handing the raw buffer to the
//! parser — if the parse error message looks like "end of
//! code" (unclosed brace, incomplete match, etc.), we tell
//! rustyline the input is incomplete and it prompts again
//! with `... `. Any other parse error is surfaced right away
//! so the user can see the problem without retyping the whole
//! block.
//!
//! **Tab completion** offers the intersection of:
//! - Bop keywords (`let`, `fn`, `if`, `use`, …)
//! - `bop::suggest::CORE_CALLABLE_BUILTINS` (language
//!   builtins)
//! - Bindings currently live in the session (via
//!   `ReplSession::binding_names`) so `let my_var = …`
//!   completes on a subsequent tab.
//! - Identifier-shaped words the user has already typed this
//!   session (covers fn parameters, struct field names, etc.
//!   that don't surface through `binding_names`).
//!
//! **Meta-commands** (lines that start with `:`):
//! - `:help` — print this list.
//! - `:vars` — list current session bindings.
//! - `:reset` — drop all bindings and start fresh.
//! - `:quit` / `:q` / `:exit` — exit.
//!
//! **History** lives at `$HOME/.bop_history`. Save-on-exit is
//! best-effort; failure doesn't abort the session.
//!
//! Engine choice: walker. REPL workloads are tiny and the
//! walker's per-input startup cost is nil; the VM's hot-loop
//! speedup doesn't pay for its compile step at this scale.

use std::cell::RefCell;
use std::process::ExitCode;
use std::rc::Rc;

use bop::{BopHost, BopLimits, ReplSession, Value};
use bop_sys::StdHost;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{Context, Editor, Helper};

/// Bop language keywords — offered as completion candidates
/// when the user hits tab on a bare prefix.
const KEYWORDS: &[&str] = &[
    "let", "const", "fn", "if", "else", "while", "repeat", "for", "in",
    "return", "break", "continue", "match", "struct", "enum", "use",
    "as", "true", "false", "none", "try",
];

/// Every meta-command the REPL recognises. Typing one of
/// these as the entire line (with optional `:` prefix) runs
/// the built-in rather than sending the text to the parser.
const META_HELP: &[(&str, &str)] = &[
    (":help", "show this help"),
    (":vars", "list bindings in the current session"),
    (":reset", "drop all session state and start fresh"),
    (":quit | :q | :exit", "exit the REPL"),
];

/// Combined `Helper` impl for rustyline. Holds:
///
/// - The set of identifier-shaped tokens the REPL has seen in
///   source lines (a superset of actual in-scope names, but
///   cheap to maintain).
/// - An `Rc<RefCell<Vec<String>>>` of session binding names
///   the outer loop refreshes after every successful eval.
///
/// The interior mutability lets tab completion stay honest
/// with respect to the session's current state without the
/// helper having to borrow the session itself.
struct BopHelper {
    typed_names: RefCell<std::collections::BTreeSet<String>>,
    session_names: Rc<RefCell<Vec<String>>>,
}

impl BopHelper {
    fn new(session_names: Rc<RefCell<Vec<String>>>) -> Self {
        Self {
            typed_names: RefCell::new(std::collections::BTreeSet::new()),
            session_names,
        }
    }

    /// Harvest identifier-shaped words from a source line and
    /// add them to the completion set. Covers fn parameters
    /// and struct field names, which never show up as session
    /// bindings on their own.
    fn absorb_names(&self, source: &str) {
        let mut out = self.typed_names.borrow_mut();
        let mut chars = source.char_indices().peekable();
        while let Some((start, ch)) = chars.next() {
            if !ch.is_alphabetic() && ch != '_' {
                continue;
            }
            let mut end = start + ch.len_utf8();
            while let Some(&(i, c)) = chars.peek() {
                if c.is_alphanumeric() || c == '_' {
                    end = i + c.len_utf8();
                    chars.next();
                } else {
                    break;
                }
            }
            let word = &source[start..end];
            if word.len() >= 2 {
                out.insert(word.to_string());
            }
        }
    }
}

impl Helper for BopHelper {}
impl Highlighter for BopHelper {}
impl Hinter for BopHelper {
    type Hint = String;
}

impl Completer for BopHelper {
    type Candidate = Pair;

    fn complete(
        &self,
        line: &str,
        pos: usize,
        _ctx: &Context<'_>,
    ) -> rustyline::Result<(usize, Vec<Pair>)> {
        // Walk backwards from the cursor to find the start of
        // the current identifier. Non-ident chars (spaces,
        // punctuation) terminate the scan.
        let prefix_start = line[..pos]
            .char_indices()
            .rev()
            .take_while(|(_, c)| c.is_alphanumeric() || *c == '_')
            .last()
            .map(|(i, _)| i)
            .unwrap_or(pos);
        let prefix = &line[prefix_start..pos];

        if prefix.is_empty() {
            return Ok((pos, Vec::new()));
        }

        let typed = self.typed_names.borrow();
        let session = self.session_names.borrow();
        let mut candidates: std::collections::BTreeSet<String> =
            std::collections::BTreeSet::new();
        for &kw in KEYWORDS {
            if kw.starts_with(prefix) {
                candidates.insert(kw.to_string());
            }
        }
        for &b in bop::suggest::CORE_CALLABLE_BUILTINS {
            if b.starts_with(prefix) {
                candidates.insert(b.to_string());
            }
        }
        for name in session.iter() {
            if name.starts_with(prefix) && name.as_str() != prefix {
                candidates.insert(name.clone());
            }
        }
        for name in typed.iter() {
            if name.starts_with(prefix) && name.as_str() != prefix {
                candidates.insert(name.clone());
            }
        }
        let pairs = candidates
            .into_iter()
            .map(|s| Pair {
                display: s.clone(),
                replacement: s,
            })
            .collect();
        Ok((prefix_start, pairs))
    }
}

/// Classify `input` as either "ready to execute" or "needs
/// more input". Extracted from the `Validator` impl so tests
/// can exercise the heuristic without going through
/// rustyline's private `ValidationContext` constructor.
fn is_incomplete_input(input: &str) -> bool {
    if input.trim().is_empty() {
        return false;
    }
    // `:` meta-commands are always complete — they never span
    // multiple lines.
    if input.trim_start().starts_with(':') {
        return false;
    }
    match bop::parse(input) {
        Ok(_) => false,
        Err(e) => {
            // Heuristic: parse errors caused by running off
            // the end of the buffer mean the user is still
            // typing. Bop's parser spells this "end of code".
            let msg = e.message.to_lowercase();
            msg.contains("end of code")
                || msg.contains("end of input")
                || msg.contains("unexpected eof")
        }
    }
}

impl Validator for BopHelper {
    fn validate(
        &self,
        ctx: &mut ValidationContext,
    ) -> rustyline::Result<ValidationResult> {
        if is_incomplete_input(ctx.input()) {
            Ok(ValidationResult::Incomplete)
        } else {
            Ok(ValidationResult::Valid(None))
        }
    }

    fn validate_while_typing(&self) -> bool {
        false
    }
}

/// Outcome of running one submission through the session. The
/// REPL loop converts this into stdout / stderr output; tests
/// inspect it directly to avoid scraping terminal text.
#[derive(Debug)]
enum StepOutcome {
    /// Nothing to print — a `let`, `fn`, etc.
    Ok,
    /// A bare expression ran; display its value.
    Value(Value),
    /// A runtime or parse error fired; render it with the
    /// source attached.
    Err(bop::BopError),
    /// User asked to exit.
    Quit,
    /// Meta-command text to echo. Distinct from `Value` so
    /// the REPL can style it differently (plain stdout line
    /// rather than an inspected value).
    Note(String),
}

/// Process one REPL submission. Pure with respect to the
/// REPL's own state (session + host), so tests can drive it
/// directly.
///
/// Meta-commands (`:help`, `:vars`, `:reset`, `:quit`) are
/// handled here before the text ever reaches the parser.
/// Everything else goes through `session.eval`.
fn step<H: BopHost>(
    session: &mut ReplSession,
    host: &mut H,
    input: &str,
) -> StepOutcome {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return StepOutcome::Ok;
    }
    if trimmed.starts_with(':') {
        return handle_meta(session, trimmed);
    }
    match session.eval(input, host, &BopLimits::standard()) {
        // `print(...)` and other side-effecting calls return
        // `Value::None`; echoing "none" in the REPL after
        // they've already produced their output is noise, so
        // suppress it. Matches Python's / node's behaviour
        // where function-statement results are only shown
        // when they're interesting.
        Ok(Some(Value::None)) => StepOutcome::Ok,
        Ok(Some(v)) => StepOutcome::Value(v),
        Ok(None) => StepOutcome::Ok,
        Err(e) => StepOutcome::Err(e),
    }
}

fn handle_meta(session: &mut ReplSession, cmd: &str) -> StepOutcome {
    match cmd {
        ":help" | ":h" | ":?" => {
            let mut out = String::from("REPL commands:\n");
            for (name, desc) in META_HELP {
                out.push_str(&format!("  {:<22} {}\n", name, desc));
            }
            StepOutcome::Note(out)
        }
        ":vars" => {
            let names = session.binding_names();
            if names.is_empty() {
                StepOutcome::Note(String::from("(no bindings yet)\n"))
            } else {
                StepOutcome::Note(format!("{}\n", names.join("\n")))
            }
        }
        ":reset" | ":clear" => {
            *session = ReplSession::new();
            StepOutcome::Note(String::from("session cleared.\n"))
        }
        ":quit" | ":q" | ":exit" => StepOutcome::Quit,
        other => StepOutcome::Note(format!(
            "unknown command `{}` — try `:help`\n",
            other
        )),
    }
}

/// Render one `StepOutcome` to the given stdout/stderr
/// writers. Split out so tests can assert on captured output
/// instead of the real terminal.
fn render_outcome<W: std::io::Write, E: std::io::Write>(
    outcome: &StepOutcome,
    source: &str,
    out: &mut W,
    err: &mut E,
) {
    match outcome {
        StepOutcome::Ok | StepOutcome::Quit => {}
        StepOutcome::Value(v) => {
            // `writeln!` failures are terminal-detached — if
            // stdout is closed the REPL is probably ending
            // soon anyway. Ignore rather than panic.
            let _ = writeln!(out, "{}", v);
        }
        StepOutcome::Err(e) => {
            let _ = write!(err, "{}", e.render(source));
        }
        StepOutcome::Note(s) => {
            let _ = write!(out, "{}", s);
        }
    }
}

/// Best-effort history-file path. Returns `None` if we can't
/// figure out a home dir (rare on current platforms); the REPL
/// then runs with no persisted history.
fn history_path() -> Option<std::path::PathBuf> {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(|h| {
            let mut p = std::path::PathBuf::from(h);
            p.push(".bop_history");
            p
        })
}

pub fn run() -> ExitCode {
    // When stdin isn't a terminal (piped input, heredoc,
    // `bop repl <<EOF ... EOF`), rustyline's multi-line
    // Validator doesn't fire between stdin lines. Fall back
    // to running the whole buffer as a single session step
    // so multi-line pipes work and bare expressions still
    // echo. Matches `python3` / `node` behaviour.
    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
        return run_non_tty();
    }

    let session_names = Rc::new(RefCell::new(Vec::<String>::new()));
    let helper = BopHelper::new(Rc::clone(&session_names));
    let mut rl = match Editor::<BopHelper, rustyline::history::FileHistory>::new() {
        Ok(r) => r,
        Err(e) => {
            eprintln!("error: couldn't initialise line editor: {}", e);
            return ExitCode::from(1);
        }
    };
    rl.set_helper(Some(helper));

    let hist = history_path();
    if let Some(ref p) = hist {
        let _ = rl.load_history(p);
    }

    let mut host = StdHost::new();
    let mut session = ReplSession::new();
    let stdout = std::io::stdout();
    let stderr = std::io::stderr();

    loop {
        match rl.readline("> ") {
            Ok(line) => {
                if line.trim().is_empty() {
                    continue;
                }
                let _ = rl.add_history_entry(line.as_str());
                if let Some(h) = rl.helper() {
                    h.absorb_names(&line);
                }
                let outcome = step(&mut session, &mut host, &line);
                let mut out = stdout.lock();
                let mut err = stderr.lock();
                render_outcome(&outcome, &line, &mut out, &mut err);
                // Refresh completer's view of the session's
                // bindings so the next tab sees anything just
                // introduced.
                *session_names.borrow_mut() = session.binding_names();
                if matches!(outcome, StepOutcome::Quit) {
                    break;
                }
            }
            Err(ReadlineError::Interrupted) => continue, // Ctrl-C
            Err(ReadlineError::Eof) => break,             // Ctrl-D
            Err(e) => {
                eprintln!("readline error: {}", e);
                break;
            }
        }
    }

    if let Some(ref p) = hist {
        let _ = rl.save_history(p);
    }

    ExitCode::SUCCESS
}

/// Non-interactive path: accumulate stdin line by line,
/// submitting each *complete* chunk to the session the same
/// way the TTY loop submits each prompt. This makes
/// transcripts piped in behave like the user typed them: a
/// multi-line `fn` decl stays as one submission (the
/// incomplete-input detector holds it open), bare
/// expressions echo their result, meta-commands work, and
/// runtime errors keep the session alive so later inputs
/// still run.
///
/// The first runtime / parse error sets the exit code to 1
/// but we keep consuming stdin until EOF — dropping later
/// input on the floor is surprising for scripts that are
/// piping structured transcripts. Callers that want
/// fail-fast semantics can run each input through a
/// separate process.
fn run_non_tty() -> ExitCode {
    use std::io::BufRead;

    let mut host = StdHost::new();
    let mut session = ReplSession::new();
    let stdout = std::io::stdout();
    let stderr = std::io::stderr();
    let mut any_error = false;

    let stdin = std::io::stdin();
    let mut buffer = String::new();
    for line in stdin.lock().lines() {
        let line = match line {
            Ok(l) => l,
            Err(e) => {
                eprintln!("error reading stdin: {}", e);
                return ExitCode::from(1);
            }
        };
        // Accumulate into the current submission. Submit when
        // the accumulated buffer parses (or its parse error
        // isn't an "unfinished input" one).
        if !buffer.is_empty() {
            buffer.push('\n');
        }
        buffer.push_str(&line);
        if is_incomplete_input(&buffer) {
            continue;
        }
        // Ready to run. Capture the submission for error
        // rendering, then clear for the next one.
        let submission = std::mem::take(&mut buffer);
        let outcome = step(&mut session, &mut host, &submission);
        let mut out = stdout.lock();
        let mut err = stderr.lock();
        render_outcome(&outcome, &submission, &mut out, &mut err);
        match outcome {
            StepOutcome::Err(_) => any_error = true,
            StepOutcome::Quit => break,
            _ => {}
        }
    }
    // Drain any trailing buffer that never became complete —
    // run it anyway so the user sees the parse error rather
    // than silent truncation.
    if !buffer.trim().is_empty() {
        let outcome = step(&mut session, &mut host, &buffer);
        let mut out = stdout.lock();
        let mut err = stderr.lock();
        render_outcome(&outcome, &buffer, &mut out, &mut err);
        if matches!(outcome, StepOutcome::Err(_)) {
            any_error = true;
        }
    }

    if any_error {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    }
}

// ─── Tests ────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    // Test host: captures prints, no module resolution.
    struct TestHost {
        prints: RefCell<Vec<String>>,
    }
    impl TestHost {
        fn new() -> Self {
            Self {
                prints: RefCell::new(Vec::new()),
            }
        }
    }
    impl BopHost for TestHost {
        fn call(
            &mut self,
            _: &str,
            _: &[Value],
            _: u32,
        ) -> Option<Result<Value, bop::BopError>> {
            None
        }
        fn on_print(&mut self, message: &str) {
            self.prints.borrow_mut().push(message.to_string());
        }
    }

    /// Drive a sequence of REPL submissions and return:
    /// - everything printed via `print(...)` (through
    ///   `on_print`), joined with `\n`;
    /// - everything the REPL would have written to stdout
    ///   (echoed values, meta-command notes), as captured
    ///   source strings.
    fn drive(inputs: &[&str]) -> (Vec<String>, Vec<String>, Vec<StepOutcome>) {
        let mut host = TestHost::new();
        let mut session = ReplSession::new();
        let mut outcomes = Vec::new();
        let mut stdout_lines = Vec::new();
        for input in inputs {
            let outcome = step(&mut session, &mut host, input);
            let mut out: Vec<u8> = Vec::new();
            let mut err: Vec<u8> = Vec::new();
            render_outcome(&outcome, input, &mut out, &mut err);
            let text = String::from_utf8(out).unwrap();
            if !text.is_empty() {
                stdout_lines.push(text);
            }
            outcomes.push(outcome);
        }
        let prints = host.prints.borrow().clone();
        (prints, stdout_lines, outcomes)
    }

    // ─── Validator / completer (unchanged from the previous
    //     iteration; here because they still need to pass).

    #[test]
    fn complete_statement_is_valid() {
        assert!(!is_incomplete_input("let x = 1"));
    }

    #[test]
    fn unclosed_fn_body_is_incomplete() {
        assert!(is_incomplete_input(
            "fn double(x) {\n    return x + x"
        ));
    }

    #[test]
    fn unclosed_if_body_is_incomplete() {
        assert!(is_incomplete_input("if true {"));
    }

    #[test]
    fn trailing_plus_is_incomplete() {
        assert!(is_incomplete_input("let x = 1 +"));
    }

    #[test]
    fn real_parse_error_is_not_incomplete() {
        assert!(!is_incomplete_input("let 1x = 2"));
    }

    #[test]
    fn empty_input_is_not_incomplete() {
        assert!(!is_incomplete_input(""));
        assert!(!is_incomplete_input("   \n\t "));
    }

    #[test]
    fn meta_command_is_always_complete() {
        // `:help` should never prompt for continuation even
        // though it isn't a valid Bop statement.
        assert!(!is_incomplete_input(":help"));
        assert!(!is_incomplete_input(":vars"));
    }

    #[test]
    fn completer_prefix_matches_keyword() {
        let session_names = Rc::new(RefCell::new(Vec::new()));
        let h = BopHelper::new(session_names);
        let (_start, cands) = h
            .complete(
                "le",
                2,
                &rustyline::Context::new(&rustyline::history::FileHistory::new()),
            )
            .unwrap();
        let disp: Vec<String> = cands.iter().map(|p| p.display.clone()).collect();
        assert!(disp.contains(&"let".to_string()));
    }

    #[test]
    fn completer_uses_session_binding_names() {
        let session_names = Rc::new(RefCell::new(vec!["my_binding".to_string()]));
        let h = BopHelper::new(Rc::clone(&session_names));
        let (_start, cands) = h
            .complete(
                "my",
                2,
                &rustyline::Context::new(&rustyline::history::FileHistory::new()),
            )
            .unwrap();
        let disp: Vec<String> = cands.iter().map(|p| p.display.clone()).collect();
        assert!(
            disp.contains(&"my_binding".to_string()),
            "expected session binding to surface in completions, got: {:?}",
            disp,
        );
    }

    // ─── State persistence through the REPL `step` fn ──────────────

    #[test]
    fn let_survives_between_steps() {
        let (prints, _, _) = drive(&["let x = 5", "print(x)"]);
        assert_eq!(prints, vec!["5"]);
    }

    #[test]
    fn fn_survives_between_steps() {
        let (prints, _, _) =
            drive(&["fn double(x) { return x + x }", "print(double(21))"]);
        assert_eq!(prints, vec!["42"]);
    }

    #[test]
    fn struct_and_method_survive_between_steps() {
        let (prints, _, _) = drive(&[
            "struct Point { x, y }\nfn Point.sum(self) { return self.x + self.y }",
            "print(Point { x: 3, y: 4 }.sum())",
        ]);
        assert_eq!(prints, vec!["7"]);
    }

    #[test]
    fn bare_expression_echoes_its_value() {
        let (_prints, stdout, _) = drive(&["let x = 5", "x + 1"]);
        // First input is `let`, no stdout. Second echoes `6`.
        assert_eq!(stdout.len(), 1);
        assert_eq!(stdout[0].trim_end(), "6");
    }

    #[test]
    fn bare_expression_does_not_echo_none_for_statements() {
        let (_prints, stdout, _) = drive(&["let x = 5"]);
        // `let` returns `Ok(None)` and nothing should have
        // gone to stdout — the REPL should not print "none".
        assert!(stdout.is_empty(), "got unexpected stdout: {:?}", stdout);
    }

    #[test]
    fn print_call_does_not_echo_trailing_none() {
        // `print(...)` runs as a bare expression: its return
        // value is `Value::None`. Before we suppressed the
        // None echo, the REPL would print the captured value
        // (`42`) via the host *and* then print "none" to
        // stdout. The suppression keeps the output clean.
        let (prints, stdout, _) = drive(&["print(42)"]);
        assert_eq!(prints, vec!["42"]);
        assert!(
            stdout.is_empty(),
            "expected no echo after print(), got: {:?}",
            stdout
        );
    }

    #[test]
    fn explicit_none_literal_is_suppressed_too() {
        // Symmetric: typing `none` at the prompt used to echo
        // "none". With the suppression, nothing goes to
        // stdout. Trade-off: users who really want to see
        // `none` can ask for `none.type()` or inspect it.
        let (_prints, stdout, _) = drive(&["none"]);
        assert!(stdout.is_empty());
    }

    #[test]
    fn error_in_step_is_captured_without_aborting() {
        let (prints, _, outcomes) = drive(&[
            "let good = 1",
            "let bad = undefined", // runtime error
            "print(good)",          // still runs; `good` survives
        ]);
        assert!(matches!(outcomes[1], StepOutcome::Err(_)));
        assert_eq!(prints, vec!["1"]);
    }

    // ─── Meta-commands ─────────────────────────────────────────────

    #[test]
    fn help_meta_command_prints_known_commands() {
        let (_prints, stdout, _) = drive(&[":help"]);
        assert_eq!(stdout.len(), 1);
        let text = &stdout[0];
        assert!(text.contains(":help"));
        assert!(text.contains(":vars"));
        assert!(text.contains(":reset"));
        assert!(text.contains(":quit"));
    }

    #[test]
    fn vars_lists_current_bindings() {
        let (_prints, stdout, _) = drive(&[
            "let alpha = 1",
            "fn beta() { return 2 }",
            ":vars",
        ]);
        // :vars output is the last line printed.
        let last = stdout.last().unwrap();
        assert!(last.contains("alpha"));
        assert!(last.contains("beta"));
    }

    #[test]
    fn reset_drops_previous_bindings() {
        let (prints, _, outcomes) = drive(&[
            "let x = 5",
            ":reset",
            "print(x)", // x is gone → runtime error
        ]);
        // :reset outcome is a Note; the subsequent print
        // errors because `x` no longer exists.
        assert!(matches!(outcomes[2], StepOutcome::Err(_)));
        assert!(prints.is_empty());
    }

    #[test]
    fn quit_signals_shutdown() {
        let (_, _, outcomes) = drive(&[":quit"]);
        assert!(matches!(outcomes[0], StepOutcome::Quit));
    }

    #[test]
    fn unknown_meta_command_surfaces_friendly_note() {
        let (_, stdout, _) = drive(&[":what"]);
        let text = stdout.last().unwrap();
        assert!(text.contains("unknown command"));
        assert!(text.contains(":help"));
    }

    // ─── Render / IO separation ────────────────────────────────────

    #[test]
    fn render_writes_value_to_stdout_not_stderr() {
        let mut out: Vec<u8> = Vec::new();
        let mut err: Vec<u8> = Vec::new();
        render_outcome(
            &StepOutcome::Value(Value::Int(42)),
            "source",
            &mut out,
            &mut err,
        );
        assert_eq!(String::from_utf8(out).unwrap().trim_end(), "42");
        assert!(err.is_empty());
    }

    #[test]
    fn render_writes_errors_to_stderr_with_source_snippet() {
        let err_val = bop::BopError::runtime_at(
            "boom",
            1,
            std::num::NonZeroU32::new(5),
        );
        let mut out: Vec<u8> = Vec::new();
        let mut err: Vec<u8> = Vec::new();
        render_outcome(
            &StepOutcome::Err(err_val),
            "let x = 1",
            &mut out,
            &mut err,
        );
        let err_text = String::from_utf8(err).unwrap();
        assert!(err_text.contains("boom"));
        assert!(err_text.contains("let x = 1"));
        assert!(out.is_empty());
    }
}