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