narju/repl/
runner.rs

1//! The interactive REPL loop and file/expression runners.
2
3use rustyline::completion::FilenameCompleter;
4use rustyline::error::ReadlineError;
5use rustyline::hint::HistoryHinter;
6use rustyline::{CompletionType, Config, EditMode, Editor};
7
8use crate::colours::*;
9use super::commands::{cmd_ctx, cmd_env, cmd_help, type_of};
10use super::highlight::NarjuHelper;
11use super::{parse_all, run_form, TopLevel};
12
13/// Banner shown when entering the raw λ↑↓ REPL (`narju --raw`).
14pub fn banner_raw() {
15    println!();
16    println!("  {BOLD}{ROSE}narju{RESET}  {DIM}λ↑↓ · Pink · Purple{RESET}");
17    println!("  {THORN}a reflective tower, dreaming in orange{RESET}");
18    println!();
19    println!("  {DIM}:help for commands · :q to quit{RESET}");
20    println!();
21}
22
23/// Banner shown when entering the Purple REPL (default `narju`).
24///
25/// Padded so box-drawing aligns: each visible line is 51 cells wide
26/// (3 leading spaces + 48 cells of box). Inner content width is 46
27/// cells; content lines pad text to 42 chars with 2 spaces each side.
28pub fn banner_purple() {
29    println!();
30    // Top: "   ╭─ narju · purple " (21) + 29 dashes + "╮" = 51
31    println!(
32        "   {BRACK}╭─ {BOLD}{ROSE}narju · purple{RESET} {BRACK}─────────────────────────────╮{RESET}"
33    );
34    // Content: "   │" (4) + "  " (2) + 42 + "  " (2) + "│" (1) = 51
35    println!(
36        "   {BRACK}│{RESET}  {THORN}a reflective tower, having compiled itself{RESET}  {BRACK}│{RESET}"
37    );
38    println!(
39        "   {BRACK}│{RESET}  {THORN}futamura 3 collapsed; the compiler is real{RESET}  {BRACK}│{RESET}"
40    );
41    // Bottom: "   ╰" (4) + 46 dashes + "╯" (1) = 51
42    println!(
43        "   {BRACK}╰──────────────────────────────────────────────╯{RESET}"
44    );
45    println!();
46    println!("   {BLOOM}bring me to life{RESET}  {DIM}·{RESET}  {DIM}^D{RESET}");
47    println!();
48}
49
50/// Read and evaluate every form in the file at `path`, printing each
51/// result unless `silent`. Returns `false` on the first read, parse, or
52/// evaluation error, after printing it to stderr — the CLI turns that
53/// into a nonzero exit.
54pub fn load_file(top: &mut TopLevel, path: &str, silent: bool) -> bool {
55    let src = match crate::io::read_source(path) {
56        Ok(s) => s,
57        Err(e) => {
58            eprintln!("{ERR}error:{RESET} could not read {ROSE}{path}{RESET}: {e}");
59            return false;
60        }
61    };
62    let forms = match parse_all(&src) {
63        Ok(f) => f,
64        Err(e) => {
65            eprintln!("{ERR}parse error{RESET} in {ROSE}{path}{RESET}:\n{DIM}{e}{RESET}");
66            return false;
67        }
68    };
69    for form in &forms {
70        match run_form(top, form) {
71            Ok(Some(r)) if !silent => {
72                println!("{PETAL}{}{RESET}", r.display());
73            }
74            Ok(_) => {}
75            Err(e) => {
76                eprintln!("{ERR}error:{RESET} {e}");
77                return false;
78            }
79        }
80    }
81    if !silent {
82        println!("{THORN}loaded {ROSE}{path}{RESET}");
83    }
84    true
85}
86
87/// Evaluate every form in `src` (the CLI's `-e` flag), printing each
88/// result. Returns `false` on the first error, after printing it to
89/// stderr.
90pub fn eval_str(top: &mut TopLevel, src: &str) -> bool {
91    let forms = match parse_all(src) {
92        Ok(f) => f,
93        Err(e) => {
94            eprintln!("{ERR}parse error:{RESET} {e}");
95            return false;
96        }
97    };
98    for form in &forms {
99        match run_form(top, form) {
100            Ok(Some(r)) => println!("{PETAL}{}{RESET}", r.display()),
101            Ok(None) => {}
102            Err(e) => {
103                eprintln!("{ERR}error:{RESET} {e}");
104                return false;
105            }
106        }
107    }
108    true
109}
110
111/// The REPL prompt for a given tower level: plain at level 0, with the
112/// level in brackets inside a `run` at level > 0.
113pub fn make_prompt(level: usize) -> String {
114    if level == 0 {
115        format!("{ROSE}narju{DIM}›{RESET} ")
116    } else {
117        format!("{ROSE}narju{DIM}[{RESET}{BLOOM}{level}{DIM}]›{RESET} ")
118    }
119}
120
121/// The user's home directory, from `$HOME` or (on Windows)
122/// `%USERPROFILE%`.
123fn dirs_home() -> Option<std::path::PathBuf> {
124    std::env::var_os("HOME")
125        .or_else(|| std::env::var_os("USERPROFILE"))
126        .map(std::path::PathBuf::from)
127}
128
129/// The raw λ↑↓ read-eval-print loop.
130///
131/// Runs until `:q` or EOF. Lines starting with `:` are REPL commands
132/// (dispatched to [`super::commands`]); everything else is parsed and
133/// evaluated in `top`, printing each result with its value kind.
134/// History persists at `~/.narju_lud_history`, distinct from the Purple
135/// REPL's history file — the two are different languages, and mixing
136/// their histories makes recall worse in both.
137pub fn run_repl(top: &mut TopLevel, quiet: bool) {
138    if !quiet {
139        banner_raw();
140    }
141
142    let config = Config::builder()
143        .history_ignore_space(true)
144        .completion_type(CompletionType::List)
145        .edit_mode(EditMode::Emacs)
146        .max_history_size(1000)
147        .unwrap()
148        .build();
149
150    let helper = NarjuHelper {
151        completer: FilenameCompleter::new(),
152        hinter: HistoryHinter::new(),
153    };
154
155    let mut rl: Editor<NarjuHelper, _> =
156        Editor::with_config(config).expect("failed to create editor");
157    rl.set_helper(Some(helper));
158
159    use rustyline::{Cmd, KeyEvent};
160    rl.bind_sequence(KeyEvent::alt('p'), Cmd::HistorySearchBackward);
161    rl.bind_sequence(KeyEvent::alt('n'), Cmd::HistorySearchForward);
162
163    let hist_path = dirs_home().map(|h| h.join(".narju_lud_history"));
164    if let Some(ref p) = hist_path {
165        let _ = rl.load_history(p);
166    }
167
168    loop {
169        let prompt = make_prompt(top.ctx.level);
170        let readline = rl.readline(&prompt);
171        match readline {
172            Ok(line) => {
173                let line = line.trim_end().to_string();
174                if line.is_empty() {
175                    continue;
176                }
177                rl.add_history_entry(&line).ok();
178
179                if let Some(cmd) = line.strip_prefix(':') {
180                    let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
181                    match parts[0] {
182                        "q" | "quit" => break,
183                        "help" => {
184                            cmd_help();
185                            continue;
186                        }
187                        "env" => {
188                            let name =
189                                parts.get(1).map(|s| s.trim()).filter(|s| !s.is_empty());
190                            cmd_env(top, name);
191                            continue;
192                        }
193                        "ctx" => {
194                            cmd_ctx(&top.ctx);
195                            continue;
196                        }
197                        "reset" => {
198                            *top = TopLevel::new();
199                            println!("  {THORN}environment cleared{RESET}");
200                            continue;
201                        }
202                        "load" => {
203                            if let Some(path) = parts.get(1) {
204                                load_file(top, path.trim(), false);
205                            } else {
206                                eprintln!("  {WARN}usage:{RESET} :load <file>");
207                            }
208                            continue;
209                        }
210                        other => {
211                            eprintln!(
212                                "  {WARN}unknown command:{RESET} :{other}  {DIM}(try :help){RESET}"
213                            );
214                            continue;
215                        }
216                    }
217                }
218
219                let forms = match parse_all(&line) {
220                    Ok(f) => f,
221                    Err(e) => {
222                        eprintln!("{ERR}parse error:{RESET} {DIM}{e}{RESET}");
223                        continue;
224                    }
225                };
226
227                for form in &forms {
228                    match run_form(top, form) {
229                        Ok(Some(r)) => {
230                            let ty = type_of(&r.val);
231                            println!(
232                                "{DIM}:{RESET} {THORN}{ty}{RESET}  {PETAL}{}{RESET}",
233                                r.display()
234                            );
235                        }
236                        Ok(None) => {}
237                        Err(e) => eprintln!("{ERR}error:{RESET} {e}"),
238                    }
239                }
240            }
241            Err(ReadlineError::Interrupted) => {
242                println!("{DIM}^C{RESET}");
243                continue;
244            }
245            Err(ReadlineError::Eof) => break,
246            Err(e) => {
247                eprintln!("{ERR}readline error:{RESET} {e}");
248                break;
249            }
250        }
251    }
252
253    if let Some(ref p) = hist_path {
254        let _ = rl.save_history(p);
255    }
256    println!("{THORN}🥀{RESET}");
257}