Skip to main content

snix_cli_eval/
repl.rs

1use crate::{
2    AllowIncomplete, Args, IncompleteInput, InterpretResult, assignment::Assignment, evaluate,
3    interpret,
4};
5use rustc_hash::FxHashMap;
6use rustyline::EventHandler::Conditional;
7use rustyline::history::DefaultHistory;
8use rustyline::{
9    Cmd, ConditionalEventHandler, Editor, Event, EventContext, KeyEvent, Movement, RepeatCount,
10    error::ReadlineError,
11};
12use smol_str::SmolStr;
13use snix_eval::{GlobalsMap, SourceCode, Value};
14use snix_glue::snix_store_io::SnixStoreIO;
15use std::io::Write;
16use std::path::PathBuf;
17use std::process::Command;
18use std::rc::Rc;
19use std::{env, fs};
20use tracing::warn;
21
22fn state_dir() -> Option<PathBuf> {
23    let mut path = dirs::data_dir();
24    if let Some(p) = path.as_mut() {
25        p.push("snix")
26    }
27    path
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub(crate) enum ReplCommand<'a> {
32    Expr(&'a str),
33    Assign(Assignment<'a>),
34    Explain(&'a str),
35    Print(&'a str),
36    Quit,
37    Help,
38}
39
40impl<'a> ReplCommand<'a> {
41    const HELP: &'static str = "
42Welcome to the Snix REPL!
43
44The following commands are supported:
45
46  <expr>       Evaluate a Nix language expression and print the result, along with its inferred type
47  <x> = <expr> Bind the result of an expression to a variable
48  :d <expr>    Evaluate a Nix language expression and print a detailed description of the result
49  :p <expr>    Evaluate a Nix language expression and print the result recursively
50  :q           Exit the REPL
51  :?, :h       Display this help text
52";
53
54    pub fn parse(input: &'a str) -> Self {
55        if input.starts_with(':') {
56            if let Some(without_prefix) = input.strip_prefix(":d ") {
57                return Self::Explain(without_prefix);
58            } else if let Some(without_prefix) = input.strip_prefix(":p ") {
59                return Self::Print(without_prefix);
60            }
61
62            let input = input.trim_end();
63            match input {
64                ":q" => return Self::Quit,
65                ":h" | ":?" => return Self::Help,
66                _ => {}
67            }
68        }
69
70        if let Some(assignment) = Assignment::parse(input) {
71            return Self::Assign(assignment);
72        }
73
74        Self::Expr(input)
75    }
76}
77
78pub struct CommandResult {
79    output: String,
80    continue_: bool,
81}
82
83impl CommandResult {
84    pub fn finalize<E: Write>(self, stdout: &mut E) -> bool {
85        write!(stdout, "{}", self.output).unwrap();
86        self.continue_
87    }
88
89    pub fn output(&self) -> &str {
90        &self.output
91    }
92}
93
94pub struct Repl<'a> {
95    /// In-progress multiline input, when the input so far doesn't parse as a complete expression
96    multiline_input: Option<String>,
97    rl: Editor<(), DefaultHistory>,
98    /// Local variables defined at the top-level in the repl
99    env: FxHashMap<SmolStr, Value>,
100
101    io_handle: Rc<SnixStoreIO>,
102    args: &'a Args,
103    source_map: SourceCode,
104    globals: Option<Rc<GlobalsMap>>,
105}
106
107struct BufferEditor;
108impl ConditionalEventHandler for BufferEditor {
109    fn handle(
110        &self,
111        _event: &Event,
112        _repeat_count: RepeatCount,
113        _positive: bool,
114        ctx: &EventContext<'_>,
115    ) -> Option<Cmd> {
116        let tempdir = tempfile::tempdir()
117            .inspect_err(|e| {
118                warn!(err=%e, "failed to create tempdir for editing");
119            })
120            .ok()?;
121
122        let editor_cmd = env::var("VISUAL")
123            .or_else(|_| env::var("EDITOR"))
124            .unwrap_or_else(|_| String::from("nano"));
125
126        let path = tempdir.path().join("expression.nix");
127
128        fs::write(&path, ctx.line())
129            .inspect_err(|e| {
130                warn!(err=%e, "failed to write buffer to file");
131            })
132            .ok()?;
133
134        Command::new(&editor_cmd)
135            .arg(&path)
136            .status()
137            .inspect_err(|e| {
138                warn!(err=%e, "editor returned with error");
139            })
140            .ok()?;
141
142        let new_content = fs::read_to_string(&path)
143            .inspect_err(|e| {
144                warn!(err=%e, "failed to read back in edited buffer");
145            })
146            .ok()?;
147
148        Some(Cmd::Replace(Movement::WholeBuffer, Some(new_content)))
149    }
150}
151
152impl<'a> Repl<'a> {
153    pub fn new(io_handle: Rc<SnixStoreIO>, args: &'a Args) -> Self {
154        let mut rl =
155            Editor::<(), DefaultHistory>::new().expect("should be able to launch rustyline");
156
157        // Registering keybind(s)
158        Self::binds(&mut rl);
159
160        Self {
161            multiline_input: None,
162            rl,
163            env: FxHashMap::default(),
164            io_handle,
165            args,
166            source_map: Default::default(),
167            globals: None,
168        }
169    }
170
171    fn binds(rl: &mut Editor<(), DefaultHistory>) {
172        rl.bind_sequence(KeyEvent::ctrl('o'), Conditional(Box::new(BufferEditor)));
173    }
174
175    pub fn run<O: Write + Clone + Send, E: Write + Clone + Send>(
176        &mut self,
177        stdout: &mut O,
178        stderr: &mut E,
179    ) {
180        if self.args.compile_only {
181            writeln!(
182                stderr,
183                "warning: `--compile-only` has no effect on REPL usage!"
184            )
185            .unwrap();
186        }
187
188        let history_path = match state_dir() {
189            // Attempt to set up these paths, but do not hard fail if it
190            // doesn't work.
191            Some(mut path) => {
192                let _ = std::fs::create_dir_all(&path);
193                path.push("history.txt");
194                let _ = self.rl.load_history(&path);
195                Some(path)
196            }
197
198            None => None,
199        };
200
201        loop {
202            let prompt = if self.multiline_input.is_some() {
203                "         > "
204            } else {
205                "snix-repl> "
206            };
207
208            let readline = self.rl.readline(prompt);
209            match readline {
210                Ok(line) => {
211                    if !self.send(stderr, line).finalize(stdout) {
212                        break;
213                    }
214                }
215                Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => break,
216
217                Err(err) => {
218                    writeln!(stderr, "error: {err}").unwrap();
219                    break;
220                }
221            }
222        }
223
224        if let Some(path) = history_path {
225            self.rl.save_history(&path).unwrap();
226        }
227    }
228
229    /// Send a line of user input to the REPL. Returns a result indicating the output to show to the
230    /// user, and whether or not to continue
231    pub fn send<E: Write + Clone + Send>(&mut self, stderr: &mut E, line: String) -> CommandResult {
232        if line.is_empty() {
233            return CommandResult {
234                output: String::new(),
235                continue_: true,
236            };
237        }
238
239        let input = if let Some(mi) = &mut self.multiline_input {
240            mi.push('\n');
241            mi.push_str(&line);
242            mi
243        } else {
244            &line
245        };
246
247        let res = match ReplCommand::parse(input) {
248            ReplCommand::Quit => {
249                return CommandResult {
250                    output: String::new(),
251                    continue_: false,
252                };
253            }
254            ReplCommand::Help => {
255                println!("{}", ReplCommand::HELP);
256                Ok(InterpretResult::empty_success(None))
257            }
258            ReplCommand::Expr(input) => interpret(
259                stderr,
260                Rc::clone(&self.io_handle),
261                input,
262                None,
263                self.args,
264                false,
265                AllowIncomplete::Allow,
266                Some(&self.env),
267                self.globals.clone(),
268                Some(self.source_map.clone()),
269            ),
270            ReplCommand::Assign(Assignment { ident, value }) => {
271                match evaluate(
272                    stderr,
273                    Rc::clone(&self.io_handle),
274                    &value.to_string(), /* FIXME: don't re-parse */
275                    None,
276                    self.args,
277                    AllowIncomplete::Allow,
278                    Some(&self.env),
279                    self.globals.clone(),
280                    Some(self.source_map.clone()),
281                ) {
282                    Ok(result) => {
283                        if let Some(value) = result.value {
284                            self.env.insert(ident.into(), value);
285                        }
286                        Ok(InterpretResult::empty_success(Some(result.globals)))
287                    }
288                    Err(incomplete) => Err(incomplete),
289                }
290            }
291            ReplCommand::Explain(input) => interpret(
292                stderr,
293                Rc::clone(&self.io_handle),
294                input,
295                None,
296                self.args,
297                true,
298                AllowIncomplete::Allow,
299                Some(&self.env),
300                self.globals.clone(),
301                Some(self.source_map.clone()),
302            ),
303            ReplCommand::Print(input) => interpret(
304                stderr,
305                Rc::clone(&self.io_handle),
306                input,
307                None,
308                &Args {
309                    strict: true,
310                    ..(self.args.clone())
311                },
312                false,
313                AllowIncomplete::Allow,
314                Some(&self.env),
315                self.globals.clone(),
316                Some(self.source_map.clone()),
317            ),
318        };
319
320        match res {
321            Ok(InterpretResult {
322                output,
323                globals,
324                success: _,
325            }) => {
326                let _ = self.rl.add_history_entry(input);
327                self.multiline_input = None;
328                if globals.is_some() {
329                    self.globals = globals;
330                }
331                CommandResult {
332                    output,
333                    continue_: true,
334                }
335            }
336            Err(IncompleteInput) => {
337                if self.multiline_input.is_none() {
338                    self.multiline_input = Some(line);
339                }
340                CommandResult {
341                    output: String::new(),
342                    continue_: true,
343                }
344            }
345        }
346    }
347}