Skip to main content

rml/
repl.rs

1//! RML — Interactive REPL (issue #29)
2//!
3//! Maintains a persistent [`Env`] between user inputs and prints diagnostics
4//! inline.  Meta-commands start with `:`:
5//!
6//! ```text
7//!   :help           show this help message
8//!   :reset          discard all state and start a fresh Env
9//!   :env            print declared terms / assignments / types / lambdas
10//!   :load <file>    evaluate a `.lino` file in the current Env
11//!   :save <file>    write the session transcript (as `.lino`) to <file>
12//!   :quit           exit the REPL (also :exit, Ctrl-D)
13//! ```
14//!
15//! Tab-completion is best-effort: the [`Repl::completion_candidates`] helper
16//! returns known meta-commands plus declared terms, operators, and lambda
17//! names.  The CLI driver (`main.rs`) wires it into the line-editor.
18
19use std::fs;
20use std::io::{self, BufRead, Write};
21use std::path::{Path, PathBuf};
22
23use crate::{evaluate_with_env, format_diagnostic, format_foundation_report, format_proof_report, Env, EnvOptions, RunResult};
24
25/// Outcome of feeding a single line into the REPL.  Output and error are
26/// stringified for the driver to emit; `exit` requests termination.
27#[derive(Debug, Clone, Default)]
28pub struct ReplStep {
29    pub output: String,
30    pub error: String,
31    pub exit: bool,
32}
33
34/// Built-in keywords always offered by the completer.
35const BUILTIN_KEYWORDS: &[&str] = &[
36    "and", "or", "not", "both", "neither", "is", "has", "probability", "range", "valence", "true",
37    "false", "unknown", "undefined", "lambda", "apply", "Pi", "Type", "Prop", "of", "type",
38];
39
40/// Meta-commands offered by the completer.
41const META_COMMANDS: &[&str] = &[
42    ":help", ":reset", ":env", ":load", ":save", ":quit", ":exit",
43];
44
45const HELP_TEXT: &str = "RML REPL — meta-commands:\n  :help           show this help message\n  :reset          discard all state and start a fresh Env\n  :env            print declared terms / assignments / types / lambdas\n  :load <file>    evaluate a .lino file in the current Env\n  :save <file>    write the session transcript (as .lino) to <file>\n  :quit           exit the REPL (also :exit, Ctrl-D)\n\nLiNo input is evaluated form-by-form.  Query results are printed; errors\nare reported as diagnostics with source spans.";
46
47fn format_number(n: f64) -> String {
48    let formatted = format!("{:.6}", n);
49    let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
50    formatted.to_string()
51}
52
53fn format_run_result(r: &RunResult) -> String {
54    match r {
55        RunResult::Num(n) => format_number(*n),
56        RunResult::Type(s) => s.clone(),
57        RunResult::Foundation(report) => format_foundation_report(report),
58        RunResult::Proof(report) => format_proof_report(report),
59    }
60}
61
62/// REPL state.  Owns the persistent [`Env`] and the running transcript so
63/// `:save` can replay the session.
64pub struct Repl {
65    pub env: Env,
66    pub transcript: Vec<String>,
67    env_options: EnvOptions,
68    cwd: PathBuf,
69}
70
71impl Repl {
72    pub fn new(env_options: EnvOptions, cwd: Option<PathBuf>) -> Self {
73        let env = Env::new(Some(env_options.clone()));
74        Self {
75            env,
76            transcript: Vec::new(),
77            env_options,
78            cwd: cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
79        }
80    }
81
82    /// Reset the env to a fresh copy of the original options and clear the
83    /// transcript.
84    pub fn reset(&mut self) {
85        self.env = Env::new(Some(self.env_options.clone()));
86        self.transcript.clear();
87    }
88
89    /// Evaluate a chunk of LiNo source against the persistent env.
90    pub fn evaluate_source(&mut self, source: &str, file: Option<&str>) -> (String, String) {
91        let res = evaluate_with_env(source, file, &mut self.env);
92        let mut out_parts: Vec<String> = Vec::new();
93        for r in &res.results {
94            out_parts.push(format_run_result(r));
95        }
96        let mut err_parts: Vec<String> = Vec::new();
97        for d in &res.diagnostics {
98            err_parts.push(format_diagnostic(d, Some(source)));
99        }
100        (out_parts.join("\n"), err_parts.join("\n"))
101    }
102
103    /// Process a single REPL line (LiNo form or meta-command).
104    pub fn feed(&mut self, line: &str) -> ReplStep {
105        let trimmed = line.trim();
106        if trimmed.is_empty() {
107            return ReplStep::default();
108        }
109        if trimmed.starts_with(':') {
110            return self.handle_meta(trimmed);
111        }
112        self.transcript.push(line.to_string());
113        let (output, error) = self.evaluate_source(line, Some("<repl>"));
114        ReplStep {
115            output,
116            error,
117            exit: false,
118        }
119    }
120
121    fn handle_meta(&mut self, line: &str) -> ReplStep {
122        let mut parts = line.splitn(2, char::is_whitespace);
123        let cmd = parts.next().unwrap_or("").trim();
124        let arg = parts.next().unwrap_or("").trim();
125        match cmd {
126            ":help" | ":?" => ReplStep {
127                output: HELP_TEXT.to_string(),
128                ..Default::default()
129            },
130            ":quit" | ":exit" => ReplStep {
131                exit: true,
132                ..Default::default()
133            },
134            ":reset" => {
135                self.reset();
136                ReplStep {
137                    output: "Env reset.".to_string(),
138                    ..Default::default()
139                }
140            }
141            ":env" => ReplStep {
142                output: format_env(&self.env),
143                ..Default::default()
144            },
145            ":load" => {
146                if arg.is_empty() {
147                    return ReplStep {
148                        error: ":load requires a file path".to_string(),
149                        ..Default::default()
150                    };
151                }
152                let path = self.resolve(arg);
153                let text = match fs::read_to_string(&path) {
154                    Ok(t) => t,
155                    Err(e) => {
156                        return ReplStep {
157                            error: format!(":load failed: {}", e),
158                            ..Default::default()
159                        };
160                    }
161                };
162                self.transcript.push(format!("# :load {}", arg));
163                self.transcript.push(text.clone());
164                let (output, error) = self.evaluate_source(&text, Some(arg));
165                ReplStep {
166                    output,
167                    error,
168                    exit: false,
169                }
170            }
171            ":save" => {
172                if arg.is_empty() {
173                    return ReplStep {
174                        error: ":save requires a file path".to_string(),
175                        ..Default::default()
176                    };
177                }
178                let path = self.resolve(arg);
179                let mut body = self.transcript.join("\n");
180                if !body.ends_with('\n') {
181                    body.push('\n');
182                }
183                if let Err(e) = fs::write(&path, body) {
184                    return ReplStep {
185                        error: format!(":save failed: {}", e),
186                        ..Default::default()
187                    };
188                }
189                ReplStep {
190                    output: format!(
191                        "Saved {} entries to {}.",
192                        self.transcript.len(),
193                        arg
194                    ),
195                    ..Default::default()
196                }
197            }
198            other => ReplStep {
199                error: format!("Unknown meta-command: {}.  Try :help.", other),
200                ..Default::default()
201            },
202        }
203    }
204
205    fn resolve(&self, p: &str) -> PathBuf {
206        let pb = Path::new(p);
207        if pb.is_absolute() || p.starts_with('~') {
208            pb.to_path_buf()
209        } else {
210            self.cwd.join(p)
211        }
212    }
213
214    /// Best-effort tab-completion candidates for `prefix`.  Returns names
215    /// from the env (terms, ops, symbols, lambdas), built-in keywords, and
216    /// meta-commands when `prefix` starts with `:`.
217    pub fn completion_candidates(&self, prefix: &str) -> Vec<String> {
218        if prefix.starts_with(':') {
219            let mut hits: Vec<String> = META_COMMANDS
220                .iter()
221                .filter(|c| c.starts_with(prefix))
222                .map(|s| s.to_string())
223                .collect();
224            hits.sort();
225            return hits;
226        }
227        let mut all: Vec<String> = Vec::new();
228        for k in BUILTIN_KEYWORDS {
229            all.push((*k).to_string());
230        }
231        for t in &self.env.terms {
232            all.push(t.clone());
233        }
234        for k in self.env.ops.keys() {
235            all.push(k.clone());
236        }
237        for k in self.env.symbol_prob.keys() {
238            all.push(k.clone());
239        }
240        for k in self.env.lambdas.keys() {
241            all.push(k.clone());
242        }
243        all.sort();
244        all.dedup();
245        if prefix.is_empty() {
246            all
247        } else {
248            all.into_iter().filter(|c| c.starts_with(prefix)).collect()
249        }
250    }
251}
252
253/// Render a snapshot of the env's user-visible state for `:env`.
254pub fn format_env(env: &Env) -> String {
255    let mut lines: Vec<String> = Vec::new();
256    lines.push(format!("range:    [{}, {}]", env.lo, env.hi));
257    let valence_str = if env.valence == 0 {
258        "continuous".to_string()
259    } else {
260        env.valence.to_string()
261    };
262    lines.push(format!("valence:  {}", valence_str));
263    if !env.terms.is_empty() {
264        let mut terms: Vec<&String> = env.terms.iter().collect();
265        terms.sort();
266        let names: Vec<String> = terms.iter().map(|s| (*s).clone()).collect();
267        lines.push(format!("terms:    {}", names.join(", ")));
268    }
269    if !env.lambdas.is_empty() {
270        let mut keys: Vec<&String> = env.lambdas.keys().collect();
271        keys.sort();
272        let names: Vec<String> = keys.iter().map(|s| (*s).clone()).collect();
273        lines.push(format!("lambdas:  {}", names.join(", ")));
274    }
275    if !env.types.is_empty() {
276        lines.push("types:".to_string());
277        let mut entries: Vec<(&String, &String)> = env.types.iter().collect();
278        entries.sort();
279        for (k, v) in entries {
280            lines.push(format!("  {} : {}", k, v));
281        }
282    }
283    if !env.assign.is_empty() {
284        lines.push("assignments:".to_string());
285        let mut entries: Vec<(&String, &f64)> = env.assign.iter().collect();
286        entries.sort_by(|a, b| a.0.cmp(b.0));
287        for (k, v) in entries {
288            lines.push(format!("  {} = {}", k, format_number(*v)));
289        }
290    }
291    // Skip default truth constants unless the user redefined them.
292    let mid = env.mid();
293    let mut user_priors: Vec<(&String, &f64)> = env
294        .symbol_prob
295        .iter()
296        .filter(|(k, v)| {
297            let kk = k.as_str();
298            if kk == "true" {
299                **v != env.hi
300            } else if kk == "false" {
301                **v != env.lo
302            } else if kk == "unknown" || kk == "undefined" {
303                **v != mid
304            } else {
305                true
306            }
307        })
308        .collect();
309    if !user_priors.is_empty() {
310        user_priors.sort_by(|a, b| a.0.cmp(b.0));
311        lines.push("symbol priors:".to_string());
312        for (k, v) in user_priors {
313            lines.push(format!("  {} = {}", k, format_number(*v)));
314        }
315    }
316    lines.join("\n")
317}
318
319/// Drive the REPL on the given input/output streams.  Used by `main.rs`.
320/// Suppresses prompts when stdin is not a TTY so piped input stays clean.
321pub fn run_repl(
322    env_options: EnvOptions,
323    show_prompt: bool,
324    input: &mut dyn BufRead,
325    output: &mut dyn Write,
326    err_output: &mut dyn Write,
327) -> io::Result<()> {
328    let mut repl = Repl::new(env_options, None);
329    if show_prompt {
330        writeln!(output, "RML REPL.  Type :help for commands, :quit to exit.")?;
331    }
332    loop {
333        if show_prompt {
334            write!(output, "rml> ")?;
335            output.flush()?;
336        }
337        let mut line = String::new();
338        let n = input.read_line(&mut line)?;
339        if n == 0 {
340            // EOF
341            if show_prompt {
342                writeln!(output)?;
343            }
344            break;
345        }
346        // Strip trailing newline only — preserve interior whitespace.
347        if line.ends_with('\n') {
348            line.pop();
349            if line.ends_with('\r') {
350                line.pop();
351            }
352        }
353        let step = repl.feed(&line);
354        if !step.output.is_empty() {
355            writeln!(output, "{}", step.output)?;
356        }
357        if !step.error.is_empty() {
358            writeln!(err_output, "{}", step.error)?;
359        }
360        if step.exit {
361            break;
362        }
363    }
364    Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn preserves_state_across_feeds() {
373        let mut repl = Repl::new(EnvOptions::default(), None);
374        repl.feed("(a: a is a)");
375        repl.feed("((a = a) has probability 1)");
376        let step = repl.feed("(? (a = a))");
377        assert_eq!(step.output, "1");
378        assert!(step.error.is_empty(), "errors: {}", step.error);
379    }
380
381    #[test]
382    fn reset_clears_state() {
383        let mut repl = Repl::new(EnvOptions::default(), None);
384        repl.feed("(a: a is a)");
385        assert!(repl.env.terms.contains("a"));
386        repl.feed(":reset");
387        assert!(!repl.env.terms.contains("a"));
388        assert!(repl.transcript.is_empty());
389    }
390
391    #[test]
392    fn help_emits_help_text() {
393        let mut repl = Repl::new(EnvOptions::default(), None);
394        let step = repl.feed(":help");
395        assert!(step.output.contains(":load"), "got: {}", step.output);
396    }
397
398    #[test]
399    fn unknown_meta_command_reports_error() {
400        let mut repl = Repl::new(EnvOptions::default(), None);
401        let step = repl.feed(":nope");
402        assert!(step.error.contains("Unknown meta-command"));
403    }
404
405    #[test]
406    fn quit_requests_exit() {
407        let mut repl = Repl::new(EnvOptions::default(), None);
408        let step = repl.feed(":quit");
409        assert!(step.exit);
410    }
411
412    #[test]
413    fn completion_offers_terms_after_declaration() {
414        let mut repl = Repl::new(EnvOptions::default(), None);
415        repl.feed("(apple: apple is apple)");
416        let hits = repl.completion_candidates("app");
417        assert!(hits.iter().any(|s| s == "apple"), "hits: {:?}", hits);
418    }
419
420    #[test]
421    fn completion_offers_meta_commands_for_colon_prefix() {
422        let repl = Repl::new(EnvOptions::default(), None);
423        let hits = repl.completion_candidates(":lo");
424        assert!(hits.iter().any(|s| s == ":load"), "hits: {:?}", hits);
425    }
426}