From 33c2d528f288e6b41faae691af594dfb192458c3 Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Thu, 12 Dec 2024 23:18:19 -0500 Subject: [PATCH] Refactor shell API for command extensibility --- cli/app.rs | 320 +++++++++++++++++++++++++++++++++------------------- cli/main.rs | 72 +++--------- 2 files changed, 221 insertions(+), 171 deletions(-) diff --git a/cli/app.rs b/cli/app.rs index 7a9ea51a4..67970ce54 100644 --- a/cli/app.rs +++ b/cli/app.rs @@ -1,10 +1,10 @@ use crate::opcodes_dictionary::OPCODE_DESCRIPTIONS; use cli_table::{Cell, Table}; -use limbo_core::{Database, RowResult, Value, IO}; +use limbo_core::{Database, RowResult, Value}; use clap::{Parser, ValueEnum}; use std::{ - io::{self, LineWriter, Write}, + io::{self, Write}, path::PathBuf, rc::Rc, str::FromStr, @@ -15,12 +15,17 @@ use std::{ }; #[derive(Parser)] +#[command(name = "limbo")] #[command(author, version, about, long_about = None)] pub struct Opts { + #[clap(index = 1)] pub database: Option, + #[clap(index = 2)] pub sql: Option, - #[clap(short, long, default_value_t = OutputMode::Raw)] + #[clap(short = 'm', long, default_value_t = OutputMode::Raw)] pub output_mode: OutputMode, + #[clap(short, long, default_value = "")] + pub output: String, } #[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] @@ -60,6 +65,29 @@ pub enum Command { ShowInfo, } +impl Command { + fn min_args(&self) -> usize { + (match self { + Self::Quit | Self::Help | Self::Opcodes | Self::ShowInfo | Self::SetOutput => 0, + Self::Open | Self::Schema | Self::OutputMode | Self::Cwd => 1, + } + 1) // argv0 + } + + fn useage(&self) -> &str { + match self { + Self::Quit => ".quit", + Self::Open => ".open ", + Self::Help => ".help", + Self::Schema => ".schema ", + Self::Opcodes => ".opcodes", + Self::OutputMode => ".mode ", + Self::SetOutput => ".output ", + Self::Cwd => ".cd ", + Self::ShowInfo => ".show", + } + } +} + impl FromStr for Command { type Err = String; fn from_str(s: &str) -> Result { @@ -78,22 +106,23 @@ impl FromStr for Command { } } +const PROMPT: &str = "limbo> "; + pub struct Limbo { - io: Arc, + pub prompt: String, + io: Arc, writer: Box, conn: Option>, - filename: Option, + output_filename: String, db_file: Option, - pub interrupt_count: Arc, - pub output_mode: OutputMode, - pub is_stdout: bool, + output_mode: OutputMode, + is_stdout: bool, + input_buff: String, } impl Limbo { #[allow(clippy::arc_with_non_send_sync)] pub fn new(opts: &Opts) -> anyhow::Result { - println!("Limbo v{}", env!("CARGO_PKG_VERSION")); - println!("Enter \".help\" for usage hints."); let io = Arc::new(limbo_core::PlatformIO::new()?); let mut db_file = None; let conn = if let Some(path) = &opts.database { @@ -105,50 +134,59 @@ impl Limbo { println!("No database file specified: Use .open to open a database."); None }; + let writer: Box = if !opts.output.is_empty() { + Box::new(std::fs::File::create(&opts.output)?) + } else { + Box::new(io::stdout()) + }; Ok(Self { - io: Arc::new(limbo_core::PlatformIO::new()?), - writer: Box::new(LineWriter::new(std::io::stdout())), - filename: None, + prompt: PROMPT.to_string(), + io, + writer, + output_filename: opts.output.clone(), conn, db_file, - interrupt_count: AtomicUsize::new(0).into(), output_mode: opts.output_mode, is_stdout: true, + input_buff: String::new(), }) } - fn show_info(&mut self) { - self.writeln("------------------------------\nCurrent settings:"); - self.writeln(format!("Output mode: {}", self.output_mode)); - let output = self - .filename - .as_ref() - .unwrap_or(&"STDOUT".to_string()) - .clone(); - self.writeln(format!("Output mode: {output}")); + fn show_info(&mut self) -> std::io::Result<()> { + self.writeln("------------------------------\nCurrent settings:")?; + self.writeln(format!("Output mode: {}", self.output_mode))?; self.writeln(format!( "DB filename: {}", self.db_file.clone().unwrap_or(":none:".to_string()) - )); + ))?; + self.writeln(format!("Output: {}", self.output_filename))?; self.writeln(format!( "CWD: {}", std::env::current_dir().unwrap().display() - )); - let _ = self.writer.flush(); + ))?; + self.writer.flush() } - pub fn close(&mut self) { + pub fn reset_input(&mut self) { + self.prompt = PROMPT.to_string(); + self.input_buff.clear(); + } + + pub fn close_conn(&mut self) { self.conn.as_mut().map(|c| c.close()); } - pub fn open_db(&mut self, path: &str) -> anyhow::Result<()> { + fn open_db(&mut self, path: &str) -> anyhow::Result<()> { let db = Database::open_file(self.io.clone(), path)?; + if self.conn.is_some() { + self.conn.as_mut().unwrap().close()?; + } self.conn = Some(db.connect()); self.db_file = Some(path.to_string()); Ok(()) } - pub fn set_output_file(&mut self, path: &str) -> io::Result<()> { + fn set_output_file(&mut self, path: &str) -> io::Result<()> { if let Ok(file) = std::fs::File::create(path) { self.writer = Box::new(file); self.is_stdout = false; @@ -158,36 +196,74 @@ impl Limbo { } fn set_output_stdout(&mut self) { + let _ = self.writer.flush(); self.writer = Box::new(io::stdout()); self.is_stdout = true; } - pub fn set_mode(&mut self, mode: OutputMode) { + fn set_mode(&mut self, mode: OutputMode) { self.output_mode = mode; } - pub fn write>(&mut self, data: D) -> io::Result<()> { + fn write>(&mut self, data: D) -> io::Result<()> { self.writer.write_all(data.as_ref()) } - pub fn writeln>(&mut self, data: D) { - self.writer.write_all(data.as_ref()).unwrap(); - self.writer.write_all(b"\n").unwrap(); - let _ = self.writer.flush(); + fn writeln>(&mut self, data: D) -> io::Result<()> { + self.writer.write_all(data.as_ref())?; + self.writer.write_all(b"\n") } - pub fn reset_interrupt_count(&self) { - self.interrupt_count - .store(0, std::sync::atomic::Ordering::SeqCst); + fn is_first_input(&self) -> bool { + self.input_buff.is_empty() } - pub fn display_help_message(&mut self) { - let _ = self.writer.write_all(HELP_MSG.as_ref()); + fn buffer_input(&mut self, line: &str) { + self.input_buff.push_str(line); + self.input_buff.push(' '); } - pub fn incr_inturrupt_count(&mut self) -> usize { - self.interrupt_count - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + pub fn handle_input_line( + &mut self, + line: &str, + interrupt_count: &Arc, + rl: &mut rustyline::DefaultEditor, + ) -> anyhow::Result<()> { + if self.is_first_input() { + if line.is_empty() { + return Ok(()); + } + if line.starts_with('.') { + self.handle_dot_command(line); + rl.add_history_entry(line.to_owned())?; + interrupt_count.store(0, Ordering::SeqCst); + return Ok(()); + } + } + if line.ends_with(';') { + self.buffer_input(line); + let buff = self.input_buff.clone(); + buff.split(';') + .map(str::trim) + .filter(|s| !s.is_empty()) + .for_each(|stmt| { + if let Err(e) = self.query(stmt, interrupt_count) { + eprintln!("{}", e); + } + }); + self.reset_input(); + } else { + self.buffer_input(line); + self.prompt = match calc_parens_offset(&self.input_buff) { + n if n < 0 => String::from(")x!...>"), + 0 => String::from(" ...> "), + n if n < 10 => format!("(x{}...> ", n), + _ => String::from("(.....> "), + }; + } + rl.add_history_entry(line.to_owned())?; + interrupt_count.store(0, Ordering::SeqCst); + Ok(()) } pub fn handle_dot_command(&mut self, line: &str) { @@ -195,94 +271,94 @@ impl Limbo { if args.is_empty() { return; } - - match Command::from_str(args[0]) { - Ok(Command::Quit) => { - println!("Exiting Limbo SQL Shell."); - self.close(); - std::process::exit(0) + if let Ok(ref cmd) = Command::from_str(args[0]) { + if args.len() < cmd.min_args() { + let _ = self.writeln(format!("Insufficient arguments: USAGE: {}", cmd.useage())); + return; } - Ok(Command::Open) => { - if args.len() < 2 { - println!("Error: No database file specified."); - } else if self.open_db(args[1]).is_err() { - println!("Error: Unable to open database file."); + match cmd { + Command::Quit => { + let _ = self.writeln("Exiting Limbo SQL Shell."); + self.close_conn(); + std::process::exit(0) } - } - Ok(Command::Schema) => { - if self.conn.is_none() { - println!("Error: no database currently open"); - return; + Command::Open => { + if self.open_db(args[1]).is_err() { + let _ = self.writeln("Error: Unable to open database file."); + } } - let table_name = args.get(1).copied(); - let _ = self.display_schema(table_name); - } - Ok(Command::Opcodes) => { - if args.len() > 1 { - for op in &OPCODE_DESCRIPTIONS { - if op.name.eq_ignore_ascii_case(args.get(1).unwrap()) { - self.writeln(format!("{}", op)); + Command::Schema => { + if self.conn.is_none() { + let _ = self.writeln("Error: no database currently open"); + return; + } + let table_name = args.get(1).copied(); + if let Err(e) = self.display_schema(table_name) { + let _ = self.writeln(format!("{}", e)); + } + } + Command::Opcodes => { + if args.len() > 1 { + for op in &OPCODE_DESCRIPTIONS { + if op.name.eq_ignore_ascii_case(args.get(1).unwrap().trim()) { + let _ = self.writeln(format!("{}", op)); + } + } + } else { + for op in &OPCODE_DESCRIPTIONS { + let _ = self.writeln(format!("{}\n", op)); } } - } else { - for op in &OPCODE_DESCRIPTIONS { - println!("{}\n", op); - } } - } - Ok(Command::OutputMode) => { - if args.len() < 2 { - println!("Error: No output mode specified."); - return; - } - match OutputMode::from_str(args[1], true) { + Command::OutputMode => match OutputMode::from_str(args[1], true) { Ok(mode) => { self.set_mode(mode); } Err(e) => { - println!("{e}"); + let _ = self.writeln(e); + } + }, + Command::SetOutput => { + if args.len() == 2 { + if let Err(e) = self.set_output_file(args[1]) { + let _ = self.writeln(format!("Error: {}", e)); + } + } else { + self.set_output_stdout(); } } - } - Ok(Command::SetOutput) => { - if args.len() == 2 { - if let Err(e) = self.set_output_file(args[1]) { - println!("Error: {}", e); + Command::Cwd => { + if args.len() < 2 { + println!("USAGE: .cd "); + return; } - } else { - self.set_output_stdout(); + let _ = std::env::set_current_dir(args[1]); + } + Command::ShowInfo => { + let _ = self.show_info(); + } + Command::Help => { + let _ = self.writeln(HELP_MSG); } } - Ok(Command::Cwd) => { - if args.len() < 2 { - println!("USAGE: .cd "); - return; - } - let _ = std::env::set_current_dir(args[1]); - } - Ok(Command::ShowInfo) => { - self.show_info(); - } - Ok(Command::Help) => { - self.display_help_message(); - } - _ => { - println!("Unknown command: {}", args[0]); - println!("enter: .help for all available commands"); - } + } else { + let _ = self.writeln(format!( + "Unknown command: {}\nenter: .help for all available commands", + args[0] + )); } } - pub fn query(&mut self, sql: &str) -> anyhow::Result<()> { + pub fn query(&mut self, sql: &str, interrupt_count: &Arc) -> anyhow::Result<()> { if self.conn.is_none() { - println!("Error: No database file specified."); + let _ = self.writeln("Error: No database file specified."); return Ok(()); } let conn = self.conn.as_ref().unwrap().clone(); match conn.query(sql) { Ok(Some(ref mut rows)) => match self.output_mode { OutputMode::Raw => loop { - if self.interrupt_count.load(Ordering::SeqCst) > 0 { + if interrupt_count.load(Ordering::SeqCst) > 0 { println!("Query interrupted."); return Ok(()); } @@ -291,7 +367,7 @@ impl Limbo { Ok(RowResult::Row(row)) => { for (i, value) in row.values.iter().enumerate() { if i > 0 { - let _ = self.write(b"|"); + let _ = self.writer.write(b"|"); } self.write( match value { @@ -306,7 +382,7 @@ impl Limbo { .as_bytes(), )?; } - self.writeln(""); + let _ = self.writeln(""); } Ok(RowResult::IO) => { self.io.run_once()?; @@ -321,7 +397,7 @@ impl Limbo { } }, OutputMode::Pretty => { - if self.interrupt_count.load(Ordering::SeqCst) > 0 { + if interrupt_count.load(Ordering::SeqCst) > 0 { println!("Query interrupted."); return Ok(()); } @@ -349,18 +425,21 @@ impl Limbo { } Ok(RowResult::Done) => break, Err(err) => { - eprintln!("{}", err); + let _ = self.writeln(format!("{}", err)); break; } } } - let table = table_rows.table(); - cli_table::print_stdout(table).unwrap(); + if let Ok(table) = table_rows.table().display() { + let _ = self.writeln(format!("{}", table)); + } else { + eprintln!("Error displaying table."); + } } }, Ok(None) => {} Err(err) => { - eprintln!("{}", err); + self.writeln(format!("{}", err)); } } // for now let's cache flush always @@ -386,7 +465,7 @@ impl Limbo { match rows.next_row()? { RowResult::Row(row) => { if let Some(Value::Text(schema)) = row.values.first() { - self.writeln(format!("{};", schema)); + let _ = self.writeln(format!("{};", schema)); found = true; } } @@ -398,9 +477,9 @@ impl Limbo { } if !found { if let Some(table_name) = table { - self.writeln(format!("Error: Table '{}' not found.", table_name)); + let _ = self.writeln(format!("Error: Table '{}' not found.", table_name)); } else { - self.writeln("No tables or indexes found in the database."); + let _ = self.writeln("No tables or indexes found in the database."); } } } @@ -430,6 +509,7 @@ In addition to standard SQL commands, the following special commands are availab Special Commands: ----------------- .quit Stop interpreting input stream and exit. +.show Display current settings. .open Open and connect to a database file. .output Change the output mode. Available modes are 'raw' and 'pretty'. .schema Show the schema of the specified table. @@ -469,3 +549,11 @@ Note: - Special commands do not require a semicolon. "#; + +fn calc_parens_offset(input: &str) -> i32 { + input.chars().fold(0, |acc, c| match c { + '(' => acc + 1, + ')' => acc - 1, + _ => acc, + }) +} diff --git a/cli/main.rs b/cli/main.rs index dab6d856c..d661450cd 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -11,9 +11,9 @@ fn main() -> anyhow::Result<()> { env_logger::init(); let opts = app::Opts::parse(); let mut app = app::Limbo::new(&opts)?; - + let interrupt_count = Arc::new(AtomicUsize::new(0)); { - let interrupt_count: Arc = app.interrupt_count.clone(); + let interrupt_count: Arc = Arc::clone(&interrupt_count); ctrlc::set_handler(move || { // Increment the interrupt count on Ctrl-C interrupt_count.fetch_add(1, Ordering::SeqCst); @@ -24,75 +24,45 @@ fn main() -> anyhow::Result<()> { if let Some(sql) = opts.sql { if sql.trim().starts_with('.') { app.handle_dot_command(&sql); - } else { - app.query(&sql)?; + } else if let Err(e) = app.query(&sql, &interrupt_count) { + eprintln!("{}", e); } return Ok(()); } + println!("Limbo v{}", env!("CARGO_PKG_VERSION")); + println!("Enter \".help\" for usage hints."); let mut rl = DefaultEditor::new()?; let home = dirs::home_dir().expect("Could not determine home directory"); let history_file = home.join(".limbo_history"); if history_file.exists() { rl.load_history(history_file.as_path())?; } - const PROMPT: &str = "limbo> "; - let mut input_buff = String::new(); - let mut prompt = PROMPT.to_string(); loop { - let readline = rl.readline(&prompt); + let readline = rl.readline(&app.prompt); match readline { - Ok(line) => { - let line = line.trim(); - if input_buff.is_empty() { - if line.is_empty() { - continue; - } - if line.starts_with('.') { - app.handle_dot_command(line); - rl.add_history_entry(line.to_owned())?; - app.reset_interrupt_count(); - continue; - } + Ok(line) => match app.handle_input_line(line.trim(), &interrupt_count, &mut rl) { + Ok(_) => {} + Err(e) => { + eprintln!("{}", e); } - if line.ends_with(';') { - input_buff.push_str(line); - input_buff.split(';').for_each(|stmt| { - if let Err(e) = app.query(stmt) { - eprintln!("{}", e); - } - }); - input_buff.clear(); - prompt = PROMPT.to_string(); - } else { - input_buff.push_str(line); - input_buff.push(' '); - prompt = match calc_parens_offset(&input_buff) { - n if n < 0 => String::from(")x!...>"), - 0 => String::from(" ...> "), - n if n < 10 => format!("(x{}...> ", n), - _ => String::from("(.....> "), - }; - } - rl.add_history_entry(line.to_owned())?; - app.reset_interrupt_count(); - } + }, Err(ReadlineError::Interrupted) => { // At prompt, increment interrupt count - if app.incr_inturrupt_count() >= 1 { + if interrupt_count.fetch_add(1, Ordering::SeqCst) >= 1 { eprintln!("Interrupted. Exiting..."); - app.close(); + app.close_conn(); break; } println!("Use .quit to exit or press Ctrl-C again to force quit."); - input_buff.clear(); + app.reset_input(); continue; } Err(ReadlineError::Eof) => { - app.close(); + app.close_conn(); break; } Err(err) => { - app.close(); + app.close_conn(); anyhow::bail!(err) } } @@ -100,11 +70,3 @@ fn main() -> anyhow::Result<()> { rl.save_history(history_file.as_path())?; Ok(()) } - -fn calc_parens_offset(input: &str) -> i32 { - input.chars().fold(0, |acc, c| match c { - '(' => acc + 1, - ')' => acc - 1, - _ => acc, - }) -}