narju/io/
terminal.rs

1//! Terminal I/O — backs the Pink-level `read` and `print` primitives
2//! when narju is run interactively (i.e. the Purple REPL).
3//!
4//! Wraps a rustyline `Editor` configured with the same look-and-feel as
5//! the raw λ↑↓ REPL: syntax-highlighted input via `NarjuHelperLite`,
6//! bracket-pair flash on cursor, multi-line input via the bracket
7//! validator, persistent history at `~/.narju_purple_history`, and
8//! Alt-p/Alt-n history search keybindings.
9//!
10//! Variable completion is *not* enabled — Pink's let-bindings live inside
11//! the booted prelude's lexical scope and aren't reachable from the Rust
12//! side. Filename completion is also disabled (out of context for a Pink
13//! REPL).
14
15use rustyline::error::ReadlineError;
16use rustyline::{Cmd, CompletionType, Config, EditMode, Editor, KeyEvent};
17
18use super::NarjuIo;
19use crate::error::NarjuError;
20use crate::repl::highlight::NarjuHelperLite;
21
22/// History filename under the home directory, distinct from the raw
23/// REPL's `.narju_lud_history`.
24const PURPLE_HISTORY_FILE: &str = ".narju_purple_history";
25
26/// A [`NarjuIo`] over a rustyline editor: highlighted, history-backed
27/// line input on stdin, plain `println!` output.
28pub struct TerminalIo {
29    /// The rustyline editor, configured in [`TerminalIo::new`].
30    editor: Editor<NarjuHelperLite, rustyline::history::DefaultHistory>,
31    /// Where history persists; `None` when no home directory exists.
32    history_path: Option<std::path::PathBuf>,
33}
34
35/// The user's home directory, from `$HOME` or (on Windows)
36/// `%USERPROFILE%`.
37fn home_dir() -> Option<std::path::PathBuf> {
38    std::env::var_os("HOME")
39        .or_else(|| std::env::var_os("USERPROFILE"))
40        .map(std::path::PathBuf::from)
41}
42
43impl TerminalIo {
44    /// Construct the editor and load any existing history. Fails when
45    /// there is no usable terminal, which is why non-interactive
46    /// embeddings use [`HeadlessIo`](super::headless::HeadlessIo)
47    /// instead.
48    pub fn new() -> Result<Self, NarjuError> {
49        let config = Config::builder()
50            .history_ignore_space(true)
51            .completion_type(CompletionType::List)
52            .edit_mode(EditMode::Emacs)
53            .max_history_size(1000)
54            .map_err(|e| {
55                NarjuError::Io(std::io::Error::other(format!("rustyline config: {e}")))
56            })?
57            .build();
58
59        let mut editor: Editor<NarjuHelperLite, _> = Editor::with_config(config).map_err(|e| {
60            NarjuError::Io(std::io::Error::other(format!("readline init: {e}")))
61        })?;
62        editor.set_helper(Some(NarjuHelperLite));
63
64        editor.bind_sequence(KeyEvent::alt('p'), Cmd::HistorySearchBackward);
65        editor.bind_sequence(KeyEvent::alt('n'), Cmd::HistorySearchForward);
66
67        let history_path = home_dir().map(|h| h.join(PURPLE_HISTORY_FILE));
68        if let Some(ref p) = history_path {
69            let _ = editor.load_history(p);
70        }
71
72        Ok(Self {
73            editor,
74            history_path,
75        })
76    }
77
78    /// Persist history to disk. Idempotent; safe to call multiple times.
79    /// Errors are silently ignored — history loss isn't worth panicking over.
80    pub fn save_history(&mut self) {
81        if let Some(ref p) = self.history_path {
82            let _ = self.editor.save_history(p);
83        }
84    }
85}
86
87impl NarjuIo for TerminalIo {
88    fn read_line(&mut self, prompt: &str) -> Result<Option<String>, NarjuError> {
89        let result = self.editor.readline(prompt);
90        // Save history opportunistically after each successful read, so
91        // even ungraceful exits (kill -9, panic) preserve recent input.
92        match &result {
93            Ok(line) => {
94                if !line.trim().is_empty() {
95                    self.editor.add_history_entry(line).ok();
96                }
97                self.save_history();
98            }
99            Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
100                self.save_history();
101            }
102            _ => {}
103        }
104        match result {
105            Ok(line) => Ok(Some(line)),
106            Err(ReadlineError::Interrupted) => Ok(None),
107            Err(ReadlineError::Eof) => Ok(None),
108            Err(e) => Err(NarjuError::Io(std::io::Error::other(format!(
109                "readline: {e}"
110            )))),
111        }
112    }
113
114    fn print(&mut self, s: &str) -> Result<(), NarjuError> {
115        println!("{s}");
116        Ok(())
117    }
118}