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