Skip to main content

rudb_cli/
shell.rs

1//! The shell itself: read a line, decide whether it is SQL or a dot command, run it, print it.
2
3use std::fmt::Write as _;
4use std::fs::{File, OpenOptions};
5use std::io::{self, BufWriter, Write};
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use rudb::{Connection, Database, Error, QueryResult, Span};
10
11use crate::args::{Command, Options};
12use crate::format::{Format, Settings, escaped, render};
13
14/// Where printed results go.
15///
16/// `.output FILE` and `.output` back again is the reason this is a type rather than a
17/// `Box<dyn Write>` handed in once. A shell that can only write to the stream it was started with
18/// cannot be used to produce a file, which is most of what the CSV and JSON modes are for.
19enum Sink {
20    /// The stream the shell was started with.
21    Given(Box<dyn Write>),
22    /// A file opened by `.output`.
23    File(BufWriter<File>, PathBuf),
24}
25
26impl Write for Sink {
27    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
28        match self {
29            Self::Given(out) => out.write(buffer),
30            Self::File(out, _) => out.write(buffer),
31        }
32    }
33
34    fn flush(&mut self) -> io::Result<()> {
35        match self {
36            Self::Given(out) => out.flush(),
37            Self::File(out, _) => out.flush(),
38        }
39    }
40}
41
42/// Why the shell stopped.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Stop {
45    /// `.quit`, `.exit`, or the end of the input.
46    Done,
47    /// An error, and `-bail` was on.
48    Failed,
49}
50
51/// A running shell.
52pub struct Shell {
53    database: Database,
54    /// The connection statements run on, which is the thing an interrupt would have to reach.
55    ///
56    /// Held beside the database rather than instead of it, because `.tables` and `.schema` read the
57    /// catalog and that is a database call. Replaced whenever `.open` replaces the database, so the
58    /// two never name different things.
59    connection: Connection,
60    settings: Settings,
61    out: Sink,
62    err: Box<dyn Write>,
63    filename: String,
64    given: Option<Box<dyn Write>>,
65    timer: bool,
66    echo: bool,
67    bail: bool,
68    failed: bool,
69    /// Where each statement's metrics document goes, and whether the file has been started yet.
70    ///
71    /// Opened on the first document rather than at startup, so that a run which never executes
72    /// anything does not leave an empty file behind for a harness to trip over.
73    metrics: Option<PathBuf>,
74    started: bool,
75}
76
77impl std::fmt::Debug for Shell {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("Shell")
80            .field("settings", &self.settings)
81            .field("failed", &self.failed)
82            .finish_non_exhaustive()
83    }
84}
85
86impl Shell {
87    /// A shell over `database`, writing results to `out` and errors to `err`.
88    pub fn new(
89        options: &Options,
90        database: Database,
91        out: Box<dyn Write>,
92        err: Box<dyn Write>,
93    ) -> Self {
94        Self {
95            connection: database.connect(),
96            database,
97            settings: options.settings.clone(),
98            out: Sink::Given(out),
99            err,
100            filename: options.database.clone(),
101            given: None,
102            timer: false,
103            echo: options.echo,
104            bail: options.bail,
105            failed: false,
106            metrics: options.metrics.clone(),
107            started: false,
108        }
109    }
110
111    /// Whether anything has failed since the shell started, which is what the exit code is.
112    pub fn failed(&self) -> bool {
113        self.failed
114    }
115
116    /// Runs everything the command line asked for, in order.
117    pub fn run_commands(&mut self, commands: &[Command]) -> Stop {
118        for command in commands {
119            let stop = match command {
120                Command::Sql(sql) => self.run_input(sql),
121                Command::File(path) => self.run_file(path),
122            };
123            if stop == Stop::Failed {
124                return Stop::Failed;
125            }
126        }
127        Stop::Done
128    }
129
130    /// Runs a file of SQL and dot commands.
131    pub fn run_file(&mut self, path: &Path) -> Stop {
132        match std::fs::read_to_string(path) {
133            Ok(text) => self.run_input(&text),
134            Err(problem) => {
135                let why = format!("Cannot open file \"{}\": {problem}", path.display());
136                self.report(&Error::io(why), "");
137                self.after_error()
138            }
139        }
140    }
141
142    /// The two lines a terminal gets before the first prompt.
143    pub fn greet(&mut self) {
144        let _ = writeln!(self.out, "rudb {}", crate::VERSION);
145        let _ = writeln!(self.out, "Enter \".help\" for usage hints.");
146        let _ = self.out.flush();
147    }
148
149    /// Reads and runs lines from a terminal until the user stops.
150    ///
151    /// The continuation marker is what says the statement is not finished, and it is the reason
152    /// [`rudb::is_complete`] exists rather than the shell guessing from a trailing semicolon.
153    pub fn prompt(&mut self, stdin: &io::Stdin) -> Stop {
154        let mut pending = String::new();
155        loop {
156            let marker = if pending.is_empty() { "D " } else { "ยท " };
157            let _ = write!(self.out, "{marker}");
158            let _ = self.out.flush();
159            let mut line = String::new();
160            match stdin.read_line(&mut line) {
161                Ok(0) => {
162                    let _ = writeln!(self.out);
163                    return Stop::Done;
164                }
165                Ok(_) => {}
166                Err(_) => return Stop::Done,
167            }
168            let line = line.trim_end_matches(['\n', '\r']);
169            if pending.is_empty() && line.trim_start().starts_with('.') {
170                if self.run_dot(line.trim()) == Stop::Failed {
171                    return Stop::Done;
172                }
173                continue;
174            }
175            if !pending.is_empty() {
176                pending.push('\n');
177            }
178            pending.push_str(line);
179            if rudb::is_complete(&pending) {
180                let statement = std::mem::take(&mut pending);
181                if self.run_sql(&statement) == Stop::Failed {
182                    return Stop::Done;
183                }
184            }
185        }
186    }
187
188    /// Runs a block of input, which may hold any mixture of dot commands and statements.
189    ///
190    /// Line oriented rather than statement oriented, because a dot command is a line and SQL is
191    /// not. Lines accumulate into a statement until the tokenizer says the statement is finished,
192    /// which is how a multi line `CREATE TABLE` works at a prompt and in a file alike.
193    pub fn run_input(&mut self, text: &str) -> Stop {
194        let mut pending = String::new();
195        for line in text.lines() {
196            if pending.trim().is_empty() && line.trim_start().starts_with('.') {
197                pending.clear();
198                if self.run_dot(line.trim()) == Stop::Failed {
199                    return Stop::Failed;
200                }
201                continue;
202            }
203            if !pending.is_empty() {
204                pending.push('\n');
205            }
206            pending.push_str(line);
207            if rudb::is_complete(&pending) {
208                let statement = std::mem::take(&mut pending);
209                if self.run_sql(&statement) == Stop::Failed {
210                    return Stop::Failed;
211                }
212            }
213        }
214        if pending.trim().is_empty() {
215            return Stop::Done;
216        }
217        self.run_sql(&pending)
218    }
219
220    /// Runs whatever statements are in one piece of text.
221    fn run_sql(&mut self, text: &str) -> Stop {
222        let found = match rudb::statements(text) {
223            Ok(found) => found,
224            Err(problem) => {
225                self.report(&problem, text);
226                return self.after_error();
227            }
228        };
229        for statement in found {
230            if self.echo {
231                let _ = writeln!(self.out, "{}", statement.sql());
232            }
233            let started = Instant::now();
234            match self.connection.execute(statement.sql()) {
235                Ok(result) => {
236                    self.record(&result);
237                    self.print(&result);
238                    if self.timer {
239                        let _ = writeln!(
240                            self.err,
241                            "Run Time (s): real {:.9}",
242                            started.elapsed().as_secs_f64()
243                        );
244                    }
245                }
246                Err(problem) => {
247                    self.report(&problem, statement.sql());
248                    return self.after_error();
249                }
250            }
251        }
252        Stop::Done
253    }
254
255    /// Appends what a statement reported about itself to the `--metrics` file.
256    ///
257    /// A statement that reported nothing writes nothing. `CREATE TABLE` goes through a path that
258    /// does not build an operator tree, so there is no document to write and a line of nulls would
259    /// be a worse answer than no line.
260    ///
261    /// A file that cannot be written to is an error like any other, and it is reported once rather
262    /// than once per statement, because a whole benchmark run reporting a full disk on every query
263    /// is how the one line that mattered gets scrolled away.
264    fn record(&mut self, result: &QueryResult) {
265        let (Some(path), Some(document)) = (self.metrics.clone(), result.metrics()) else {
266            return;
267        };
268        let opened = if self.started {
269            OpenOptions::new().append(true).open(&path)
270        } else {
271            File::create(&path)
272        };
273        let written = opened.and_then(|mut file| writeln!(file, "{}", document.one_line()));
274        match written {
275            Ok(()) => self.started = true,
276            Err(problem) => {
277                self.metrics = None;
278                let _ = writeln!(
279                    self.err,
280                    "Error: cannot write metrics to \"{}\": {problem}",
281                    path.display()
282                );
283                let _ = self.err.flush();
284                self.failed = true;
285            }
286        }
287    }
288
289    /// Prints a result, unless it is the empty one a writing statement hands back.
290    fn print(&mut self, result: &QueryResult) {
291        let text = render(result, &self.settings);
292        if !text.is_empty() {
293            let _ = write!(self.out, "{text}");
294            let _ = self.out.flush();
295        }
296    }
297
298    /// What an error does to the run, which depends on `-bail`.
299    fn after_error(&mut self) -> Stop {
300        self.failed = true;
301        if self.bail { Stop::Failed } else { Stop::Done }
302    }
303
304    /// Prints an error the way DuckDB prints one: the message, then the line it is about with a
305    /// caret under the offending token.
306    fn report(&mut self, problem: &Error, sql: &str) {
307        let _ = writeln!(self.err, "{problem}");
308        if let Some(span) = problem.span() {
309            if let Some(text) = pointer(sql, span) {
310                let _ = writeln!(self.err);
311                let _ = write!(self.err, "{text}");
312            }
313        }
314        let _ = self.err.flush();
315    }
316
317    /// Runs one dot command.
318    fn run_dot(&mut self, line: &str) -> Stop {
319        let mut words = split(line);
320        if words.is_empty() {
321            return Stop::Done;
322        }
323        let name = words.remove(0);
324        let argument = |at: usize| words.get(at).cloned().unwrap_or_default();
325        match name.as_str() {
326            ".quit" | ".exit" => return Stop::Failed,
327            ".help" => {
328                let _ = write!(self.out, "{}", crate::help::DOT_COMMANDS);
329            }
330            ".mode" => {
331                if words.is_empty() {
332                    let _ =
333                        writeln!(self.out, "current output mode: {}", self.settings.format.name());
334                } else if let Some(format) = Format::from_name(&argument(0)) {
335                    self.settings.set_format(format);
336                    if let Some(table) = words.get(1) {
337                        self.settings.table = table.clone();
338                    }
339                } else {
340                    return self.complain(&format!(
341                        "Error: mode should be one of: {}",
342                        crate::help::MODES
343                    ));
344                }
345            }
346            ".headers" | ".header" => self.settings.header = on(&argument(0)),
347            ".separator" => {
348                self.settings.separator = argument(0);
349                if let Some(newline) = words.get(1) {
350                    self.settings.newline = newline.clone();
351                }
352            }
353            ".nullvalue" | ".nullValue" => self.settings.nullvalue = argument(0),
354            ".timer" => self.timer = on(&argument(0)),
355            ".echo" => self.echo = on(&argument(0)),
356            ".bail" => self.bail = on(&argument(0)),
357            ".print" => {
358                let _ = writeln!(self.out, "{}", words.join(" "));
359            }
360            ".read" => return self.run_file(Path::new(&argument(0))),
361            ".output" => return self.redirect(words.first().map(String::as_str)),
362            ".tables" => self.tables(words.first().map(String::as_str)),
363            ".schema" => self.schema(words.first().map(String::as_str)),
364            ".databases" => {
365                let _ = writeln!(self.out, "memory:");
366            }
367            ".show" => self.show(),
368            ".open" => {
369                // The library decides what a name means, so `.open :memory:` is a new empty
370                // database here the same way it is for a program, and a file is the library's
371                // sentence about the format that is missing rather than a second one written here.
372                match Database::open(&argument(0)) {
373                    Ok(database) => {
374                        self.connection = database.connect();
375                        self.database = database;
376                    }
377                    Err(problem) => return self.complain(&format!("Error: {}", problem.message())),
378                }
379            }
380            other => {
381                return self
382                    .complain(&format!("Error: unknown command or invalid arguments:  \"{}\". Enter \".help\" for help", other.trim_start_matches('.')));
383            }
384        }
385        let _ = self.out.flush();
386        Stop::Done
387    }
388
389    /// Prints a complaint about a dot command, which is an error like any other.
390    fn complain(&mut self, message: &str) -> Stop {
391        let _ = writeln!(self.err, "{message}");
392        let _ = self.err.flush();
393        self.after_error()
394    }
395
396    /// `.output`, both directions.
397    fn redirect(&mut self, path: Option<&str>) -> Stop {
398        let _ = self.out.flush();
399        match path {
400            None | Some("stdout") => {
401                if let Some(given) = self.given.take() {
402                    self.out = Sink::Given(given);
403                }
404            }
405            Some(path) => {
406                let path = PathBuf::from(path);
407                match File::create(&path) {
408                    Ok(file) => {
409                        let opened = Sink::File(BufWriter::new(file), path);
410                        if let Sink::Given(given) = std::mem::replace(&mut self.out, opened) {
411                            self.given = Some(given);
412                        }
413                    }
414                    Err(problem) => {
415                        return self.complain(&format!(
416                            "Error: cannot open \"{}\": {problem}",
417                            path.display()
418                        ));
419                    }
420                }
421            }
422        }
423        Stop::Done
424    }
425
426    /// `.tables`, one name per line.
427    fn tables(&mut self, pattern: Option<&str>) {
428        let mut names = self.database.table_names();
429        names.sort();
430        for name in names {
431            if pattern.is_none_or(|pattern| matches(&name, pattern)) {
432                let _ = writeln!(self.out, "{name}");
433            }
434        }
435    }
436
437    /// `.schema`, the `CREATE TABLE` for every table or for one of them.
438    fn schema(&mut self, wanted: Option<&str>) {
439        let mut names = self.database.table_names();
440        names.sort();
441        for name in names {
442            if wanted.is_some_and(|wanted| !matches(&name, wanted)) {
443                continue;
444            }
445            if let Ok(sql) = self.database.table_sql(&name) {
446                let _ = writeln!(self.out, "{sql}");
447            }
448        }
449    }
450
451    /// `.show`, in the order and the spacing DuckDB prints it.
452    ///
453    /// `width` is blank because there is no column width setting yet, and it is listed anyway so
454    /// that a script reading this output finds the line where it expects it.
455    fn show(&mut self) {
456        let mut out = String::new();
457        let _ = writeln!(out, "        echo: {}", off_on(self.echo));
458        let _ = writeln!(out, "     headers: {}", off_on(self.settings.header));
459        let _ = writeln!(out, "        mode: {}", self.settings.format.name());
460        let _ = writeln!(out, "   nullvalue: \"{}\"", self.settings.nullvalue);
461        let _ = writeln!(out, "      output: {}", self.output_name());
462        let _ = writeln!(out, "colseparator: \"{}\"", escaped(&self.settings.separator));
463        let _ = writeln!(out, "rowseparator: \"{}\"", escaped(&self.settings.newline));
464        let _ = writeln!(out, "       width: ");
465        let _ = writeln!(out, "    filename: {}", self.filename);
466        let _ = write!(self.out, "{out}");
467    }
468
469    /// What `.show` calls the place output is going.
470    fn output_name(&self) -> String {
471        match &self.out {
472            Sink::Given(_) => "stdout".to_string(),
473            Sink::File(_, path) => path.display().to_string(),
474        }
475    }
476}
477
478/// Whether a name matches a `.tables` or `.schema` pattern, where `%` stands for any run.
479fn matches(name: &str, pattern: &str) -> bool {
480    let pattern = pattern.trim_matches('\'');
481    if let Some(prefix) = pattern.strip_suffix('%') {
482        name.starts_with(prefix)
483    } else {
484        name.eq_ignore_ascii_case(pattern)
485    }
486}
487
488/// How a dot command's arguments are split: on whitespace, with quoted runs kept together.
489fn split(line: &str) -> Vec<String> {
490    let mut words = Vec::new();
491    let mut current = String::new();
492    let mut quote = None;
493    let mut started = false;
494    for character in line.chars() {
495        match quote {
496            Some(open) if character == open => quote = None,
497            Some(_) => current.push(character),
498            None if character == '\'' || character == '"' => {
499                quote = Some(character);
500                started = true;
501            }
502            None if character.is_whitespace() => {
503                if started || !current.is_empty() {
504                    words.push(std::mem::take(&mut current));
505                    started = false;
506                }
507            }
508            None => current.push(character),
509        }
510    }
511    if started || !current.is_empty() {
512        words.push(current);
513    }
514    words
515}
516
517/// How a dot command spells a boolean, where anything that is not a recognized "off" is "on".
518fn on(word: &str) -> bool {
519    !matches!(word, "off" | "0" | "false" | "no")
520}
521
522/// How `.show` spells one back.
523fn off_on(flag: bool) -> &'static str {
524    if flag { "on" } else { "off" }
525}
526
527/// The `LINE n:` and caret that go under an error message.
528///
529/// `None` when the span does not point into the text, which happens for an error raised about a
530/// statement the caller did not hand us, and printing a caret under the wrong thing is worse than
531/// printing none.
532fn pointer(sql: &str, span: Span) -> Option<String> {
533    let start = span.start as usize;
534    if start > sql.len() || !sql.is_char_boundary(start) {
535        return None;
536    }
537    let before = &sql[..start];
538    let number = before.matches('\n').count() + 1;
539    let line_start = before.rfind('\n').map_or(0, |at| at + 1);
540    let line_end = sql[line_start..].find('\n').map_or(sql.len(), |at| line_start + at);
541    let line = &sql[line_start..line_end];
542    let prefix = format!("LINE {number}: ");
543    let column = sql[line_start..start].chars().count();
544    Some(format!("{prefix}{line}\n{}^\n", " ".repeat(prefix.chars().count() + column)))
545}
546
547#[cfg(test)]
548mod tests {
549    use super::{matches, on, pointer, split};
550    use rudb::Span;
551
552    #[test]
553    fn a_dot_command_splits_on_whitespace() {
554        assert_eq!(split(".mode csv"), vec![".mode", "csv"]);
555        assert_eq!(split("  .timer   on  "), vec![".timer", "on"]);
556    }
557
558    #[test]
559    fn a_quoted_argument_keeps_its_spaces() {
560        assert_eq!(split(".separator ' | '"), vec![".separator", " | "]);
561        assert_eq!(split(".nullvalue \"\""), vec![".nullvalue", ""]);
562    }
563
564    #[test]
565    fn off_is_the_only_way_to_turn_something_off() {
566        assert!(on("on"));
567        assert!(on(""));
568        assert!(!on("off"));
569        assert!(!on("0"));
570    }
571
572    #[test]
573    fn a_pattern_ending_in_a_percent_is_a_prefix() {
574        assert!(matches("orders", "orders"));
575        assert!(matches("orders", "ORDERS"));
576        assert!(matches("orders", "ord%"));
577        assert!(!matches("orders", "lineitem"));
578    }
579
580    #[test]
581    fn the_caret_lands_under_the_span() {
582        let sql = "SELECT nosuch";
583        let text = pointer(sql, Span::new(7, 13)).expect("a pointer");
584        assert_eq!(text, "LINE 1: SELECT nosuch\n               ^\n");
585    }
586
587    #[test]
588    fn the_caret_counts_lines() {
589        let sql = "SELECT\n  nosuch";
590        let text = pointer(sql, Span::new(9, 15)).expect("a pointer");
591        assert_eq!(text, "LINE 2:   nosuch\n          ^\n");
592    }
593
594    #[test]
595    fn a_span_past_the_end_gets_no_pointer() {
596        assert!(pointer("SELECT 1", Span::new(100, 101)).is_none());
597    }
598}