diff --git a/Cargo.lock b/Cargo.lock index a4cc9dcea..83db58c03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,13 +411,11 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "clipboard-win" -version = "4.5.0" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" dependencies = [ "error-code", - "str-buf", - "winapi", ] [[package]] @@ -824,13 +822,9 @@ dependencies = [ [[package]] name = "error-code" -version = "2.3.1" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" -dependencies = [ - "libc", - "str-buf", -] +checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" [[package]] name = "fallible-iterator" @@ -858,13 +852,13 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fd-lock" -version = "3.0.13" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" +checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -1609,6 +1603,7 @@ name = "limbo_cli" version = "0.0.16" dependencies = [ "anyhow", + "cfg-if", "clap", "comfy-table", "csv", @@ -1620,6 +1615,14 @@ dependencies = [ "rustyline", ] +[[package]] +name = "limbo_completion" +version = "0.0.15" +dependencies = [ + "limbo_ext", + "mimalloc", +] + [[package]] name = "limbo_core" version = "0.0.16" @@ -1639,6 +1642,7 @@ dependencies = [ "julian_day_converter", "libc", "libloading", + "limbo_completion", "limbo_crypto", "limbo_ext", "limbo_ipaddr", @@ -2748,9 +2752,9 @@ checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "rustyline" -version = "12.0.0" +version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -2760,13 +2764,24 @@ dependencies = [ "libc", "log", "memchr", - "nix 0.26.4", + "nix 0.29.0", "radix_trie", - "scopeguard", + "rustyline-derive", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width 0.2.0", "utf8parse", - "winapi", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustyline-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327e9d075f6df7e25fbf594f1be7ef55cf0d567a6cb5112eeccbbd51ceb48e0d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", ] [[package]] @@ -2898,12 +2913,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - [[package]] name = "str_stack" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 712ce90fb..e66fbb044 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,8 @@ members = [ "bindings/rust", "bindings/wasm", "cli", - "core", + "core", + "extensions/completion", "extensions/core", "extensions/crypto", "extensions/kvstore", @@ -45,6 +46,8 @@ limbo_time = { path = "extensions/time", version = "0.0.16" } limbo_uuid = { path = "extensions/uuid", version = "0.0.16" } limbo_sqlite3_parser = { path = "vendored/sqlite3-parser", version = "0.0.16" } limbo_ipaddr = { path = "extensions/ipaddr", version = "0.0.16" } +limbo_completion = { path = "extensions/completion", version = "0.0.16" } + # Config for 'cargo dist' [workspace.metadata.dist] # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 784168f8c..965bae003 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -24,11 +24,12 @@ clap = { version = "4.5", features = ["derive"] } comfy-table = "7.1.4" dirs = "5.0.1" env_logger = "0.10.1" -limbo_core = { path = "../core" } -rustyline = "12.0.0" +limbo_core = { path = "../core", default-features = true, features = ["completion"]} +rustyline = {version = "15.0.0", default-features = true, features = ["derive"]} ctrlc = "3.4.4" csv = "1.3.1" miette = { version = "7.4.0", features = ["fancy"] } +cfg-if = "1.0.0" [features] default = ["io_uring"] diff --git a/cli/app.rs b/cli/app.rs index 9925585c0..bbc49ac46 100644 --- a/cli/app.rs +++ b/cli/app.rs @@ -1,4 +1,5 @@ use crate::{ + helper::LimboHelper, import::{ImportFile, IMPORT_HELP}, input::{get_io, get_writer, DbLocation, Io, OutputMode, Settings, HELP_MSG}, opcodes_dictionary::OPCODE_DESCRIPTIONS, @@ -7,7 +8,7 @@ use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Row, Table use limbo_core::{Database, LimboError, OwnedValue, Statement, StepResult}; use clap::{Parser, ValueEnum}; -use rustyline::DefaultEditor; +use rustyline::{history::DefaultHistory, Editor}; use std::{ fmt, io::{self, Write}, @@ -167,7 +168,7 @@ pub struct Limbo<'a> { pub interrupt_count: Arc, input_buff: String, opts: Settings, - pub rl: &'a mut DefaultEditor, + pub rl: &'a mut Editor, } macro_rules! query_internal { @@ -196,7 +197,7 @@ macro_rules! query_internal { } impl<'a> Limbo<'a> { - pub fn new(rl: &'a mut rustyline::DefaultEditor) -> anyhow::Result { + pub fn new(rl: &'a mut rustyline::Editor) -> anyhow::Result { let opts = Opts::parse(); let db_file = opts .database @@ -211,6 +212,8 @@ impl<'a> Limbo<'a> { }; let db = Database::open_file(io.clone(), &db_file)?; let conn = db.connect(); + let h = LimboHelper::new(conn.clone(), io.clone()); + rl.set_helper(Some(h)); let interrupt_count = Arc::new(AtomicUsize::new(0)); { let interrupt_count: Arc = Arc::clone(&interrupt_count); diff --git a/cli/helper.rs b/cli/helper.rs new file mode 100644 index 000000000..3d809741f --- /dev/null +++ b/cli/helper.rs @@ -0,0 +1,123 @@ +use std::rc::Rc; +use std::sync::Arc; + +use limbo_core::{Connection, StepResult}; +use rustyline::completion::{extract_word, Completer, Pair}; +use rustyline::highlight::Highlighter; +use rustyline::{Completer, Helper, Hinter, Validator}; + +macro_rules! try_result { + ($expr:expr, $err:expr) => { + match $expr { + Ok(val) => val, + Err(_) => return Ok($err), + } + }; +} + +#[derive(Helper, Completer, Hinter, Validator)] +pub struct LimboHelper { + #[rustyline(Completer)] + completer: SqlCompleter, +} + +impl LimboHelper { + pub fn new(conn: Rc, io: Arc) -> Self { + LimboHelper { + completer: SqlCompleter::new(conn, io), + } + } +} + +impl Highlighter for LimboHelper {} + +pub struct SqlCompleter { + conn: Rc, + io: Arc, +} + +impl SqlCompleter { + pub fn new(conn: Rc, io: Arc) -> Self { + Self { conn, io } + } +} + +// Got this from the FilenameCompleter. +// TODO have to see what chars break words in Sqlite +cfg_if::cfg_if! { + if #[cfg(unix)] { + // rl_basic_word_break_characters, rl_completer_word_break_characters + const fn default_break_chars(c : char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '"' | '\\' | '\'' | '`' | '@' | '$' | '>' | '<' | '=' | ';' | '|' | '&' | + '{' | '(' | '\0') + } + const ESCAPE_CHAR: Option = Some('\\'); + // In double quotes, not all break_chars need to be escaped + // https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html + #[allow(dead_code)] + const fn double_quotes_special_chars(c: char) -> bool { matches!(c, '"' | '$' | '\\' | '`') } + } else if #[cfg(windows)] { + // Remove \ to make file completion works on windows + const fn default_break_chars(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '"' | '\'' | '`' | '@' | '$' | '>' | '<' | '=' | ';' | '|' | '&' | '{' | + '(' | '\0') + } + const ESCAPE_CHAR: Option = None; + #[allow(dead_code)] + const fn double_quotes_special_chars(c: char) -> bool { c == '"' } // TODO Validate: only '"' ? + } else if #[cfg(target_arch = "wasm32")] { + const fn default_break_chars(c: char) -> bool { false } + const ESCAPE_CHAR: Option = None; + #[allow(dead_code)] + const fn double_quotes_special_chars(c: char) -> bool { false } + } +} + +impl Completer for SqlCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + // TODO: have to differentiate words if they are enclosed in single of double quotes + let (prefix_pos, prefix) = extract_word(line, pos, ESCAPE_CHAR, default_break_chars); + let mut candidates = Vec::new(); + + let query = try_result!( + self.conn.query(format!( + "SELECT candidate FROM completion('{prefix}', '{line}') ORDER BY 1;" + )), + (prefix_pos, candidates) + ); + + if let Some(mut rows) = query { + loop { + match try_result!(rows.step(), (prefix_pos, candidates)) { + StepResult::Row => { + let row = rows.row().unwrap(); + let completion: &str = + try_result!(row.get::<&str>(0), (prefix_pos, candidates)); + let pair = Pair { + display: completion.to_string(), + replacement: completion.to_string(), + }; + candidates.push(pair); + } + StepResult::IO => { + try_result!(self.io.run_once(), (prefix_pos, candidates)); + } + StepResult::Interrupt => break, + StepResult::Done => break, + StepResult::Busy => { + break; + } + } + } + } + + Ok((prefix_pos, candidates)) + } +} diff --git a/cli/main.rs b/cli/main.rs index e59008a8b..d118c2c77 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -1,15 +1,22 @@ #![allow(clippy::arc_with_non_send_sync)] mod app; +mod helper; mod import; mod input; mod opcodes_dictionary; -use rustyline::{error::ReadlineError, DefaultEditor}; +use rustyline::{error::ReadlineError, Config, Editor}; use std::sync::atomic::Ordering; +fn rustyline_config() -> Config { + Config::builder() + .completion_type(rustyline::CompletionType::List) + .build() +} + fn main() -> anyhow::Result<()> { env_logger::init(); - let mut rl = DefaultEditor::new()?; + let mut rl = Editor::with_config(rustyline_config())?; let mut app = app::Limbo::new(&mut rl)?; let home = dirs::home_dir().expect("Could not determine home directory"); let history_file = home.join(".limbo_history"); diff --git a/core/Cargo.toml b/core/Cargo.toml index 3bc30f5a4..c72bbc5c8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -25,6 +25,7 @@ time = ["limbo_time/static"] crypto = ["limbo_crypto/static"] series = ["limbo_series/static"] ipaddr = ["limbo_ipaddr/static"] +completion = ["limbo_completion/static"] [target.'cfg(target_os = "linux")'.dependencies] io-uring = { version = "0.6.1", optional = true } @@ -64,6 +65,7 @@ limbo_time = { workspace = true, optional = true, features = ["static"] } limbo_crypto = { workspace = true, optional = true, features = ["static"] } limbo_series = { workspace = true, optional = true, features = ["static"] } limbo_ipaddr = { workspace = true, optional = true, features = ["static"] } +limbo_completion = { workspace = true, optional = true, features = ["static"] } miette = "7.4.0" strum = "0.26" parking_lot = "0.12.3" diff --git a/core/ext/mod.rs b/core/ext/mod.rs index a88468f3b..961cf5a85 100644 --- a/core/ext/mod.rs +++ b/core/ext/mod.rs @@ -154,6 +154,10 @@ impl Database { if unsafe { !limbo_ipaddr::register_extension_static(&ext_api).is_ok() } { return Err("Failed to register ipaddr extension".to_string()); } + #[cfg(feature = "completion")] + if unsafe { !limbo_completion::register_extension_static(&ext_api).is_ok() } { + return Err("Failed to register completion extension".to_string()); + } Ok(()) } } diff --git a/extensions/completion/Cargo.toml b/extensions/completion/Cargo.toml new file mode 100644 index 000000000..dace3cc8a --- /dev/null +++ b/extensions/completion/Cargo.toml @@ -0,0 +1,19 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +name = "limbo_completion" +repository.workspace = true +version.workspace = true + +[dependencies] +limbo_ext = { workspace = true, features = ["static"] } + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +mimalloc = { version = "0.1", default-features = false } + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +static = ["limbo_ext/static"] diff --git a/extensions/completion/src/keywords.rs b/extensions/completion/src/keywords.rs new file mode 100644 index 000000000..f8eff0485 --- /dev/null +++ b/extensions/completion/src/keywords.rs @@ -0,0 +1,149 @@ +pub(crate) static KEYWORDS: [&str; 147] = [ + "ABORT", + "ACTION", + "ADD", + "AFTER", + "ALL", + "ALTER", + "ALWAYS", + "ANALYZE", + "AND", + "AS", + "ASC", + "ATTACH", + "AUTOINCREMENT", + "BEFORE", + "BEGIN", + "BETWEEN", + "BY", + "CASCADE", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COLUMN", + "COMMIT", + "CONFLICT", + "CONSTRAINT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "DATABASE", + "DEFAULT", + "DEFERRABLE", + "DEFERRED", + "DELETE", + "DESC", + "DETACH", + "DISTINCT", + "DO", + "DROP", + "EACH", + "ELSE", + "END", + "ESCAPE", + "EXCEPT", + "EXCLUDE", + "EXCLUSIVE", + "EXISTS", + "EXPLAIN", + "FAIL", + "FILTER", + "FIRST", + "FOLLOWING", + "FOR", + "FOREIGN", + "FROM", + "FULL", + "GENERATED", + "GLOB", + "GROUP", + "GROUPS", + "HAVING", + "IF", + "IGNORE", + "IMMEDIATE", + "IN", + "INDEX", + "INDEXED", + "INITIALLY", + "INNER", + "INSERT", + "INSTEAD", + "INTERSECT", + "INTO", + "IS", + "ISNULL", + "JOIN", + "KEY", + "LAST", + "LEFT", + "LIKE", + "LIMIT", + "MATCH", + "MATERIALIZED", + "NATURAL", + "NO", + "NOT", + "NOTHING", + "NOTNULL", + "NULL", + "NULLS", + "OF", + "OFFSET", + "ON", + "OR", + "ORDER", + "OTHERS", + "OUTER", + "OVER", + "PARTITION", + "PLAN", + "PRAGMA", + "PRECEDING", + "PRIMARY", + "QUERY", + "RAISE", + "RANGE", + "RECURSIVE", + "REFERENCES", + "REGEXP", + "REINDEX", + "RELEASE", + "RENAME", + "REPLACE", + "RESTRICT", + "RETURNING", + "RIGHT", + "ROLLBACK", + "ROW", + "ROWS", + "SAVEPOINT", + "SELECT", + "SET", + "TABLE", + "TEMP", + "TEMPORARY", + "THEN", + "TIES", + "TO", + "TRANSACTION", + "TRIGGER", + "UNBOUNDED", + "UNION", + "UNIQUE", + "UPDATE", + "USING", + "VACUUM", + "VALUES", + "VIEW", + "VIRTUAL", + "WHEN", + "WHERE", + "WINDOW", + "WITH", + "WITHOUT", +]; diff --git a/extensions/completion/src/lib.rs b/extensions/completion/src/lib.rs new file mode 100644 index 000000000..09b09c479 --- /dev/null +++ b/extensions/completion/src/lib.rs @@ -0,0 +1,217 @@ +//! Reference for implementation +//! https://github.com/sqlite/sqlite/blob/a80089c5167856f0aadc9c878bd65843df724c06/ext/misc/completion.c + +mod keywords; + +use keywords::KEYWORDS; +use limbo_ext::{register_extension, ResultCode, VTabCursor, VTabModule, VTabModuleDerive, Value}; + +register_extension! { + vtabs: { CompletionVTab } +} + +macro_rules! try_option { + ($expr:expr, $err:expr) => { + match $expr { + Some(val) => val, + None => return $err, + } + }; +} + +#[derive(Debug, Default, PartialEq, Clone)] +enum CompletionPhase { + #[default] + Keywords = 1, + // TODO other options now implemented for now + // Pragmas = 2, + // Functions = 3, + // Collations = 4, + // Indexes = 5, + // Triggers = 6, + // Databases = 7, + // Tables = 8, // Also VIEWs and TRIGGERs + // Columns = 9, + // Modules = 10, + Eof = 11, +} + +impl Into for CompletionPhase { + fn into(self) -> i64 { + use self::CompletionPhase::*; + match self { + Keywords => 1, + // Pragmas => 2, + // Functions => 3, + // Collations => 4, + // Indexes => 5, + // Triggers => 6, + // Databases => 7, + // Tables => 8, + // Columns => 9, + // Modules => 10, + Eof => 11, + } + } +} + +/// A virtual table that generates candidate completions +#[derive(Debug, Default, VTabModuleDerive)] +struct CompletionVTab {} + +impl VTabModule for CompletionVTab { + type VCursor = CompletionCursor; + const NAME: &'static str = "completion"; + const VTAB_KIND: limbo_ext::VTabKind = limbo_ext::VTabKind::TableValuedFunction; + type Error = ResultCode; + + fn create_schema(_args: &[Value]) -> String { + "CREATE TABLE completion( + candidate TEXT, + prefix TEXT HIDDEN, + wholeline TEXT HIDDEN, + phase INT HIDDEN + )" + .to_string() + } + + fn open(&self) -> Result { + Ok(CompletionCursor::default()) + } + + fn column(cursor: &Self::VCursor, idx: u32) -> Result { + cursor.column(idx) + } + + fn next(cursor: &mut Self::VCursor) -> ResultCode { + cursor.next() + } + + fn eof(cursor: &Self::VCursor) -> bool { + cursor.eof() + } + + fn filter(cursor: &mut Self::VCursor, args: &[Value]) -> ResultCode { + if args.len() == 0 || args.len() > 2 { + return ResultCode::InvalidArgs; + } + cursor.reset(); + let prefix = try_option!(args[0].to_text(), ResultCode::InvalidArgs); + + let wholeline = args.get(1).map(|v| v.to_text().unwrap_or("")).unwrap_or(""); + + cursor.line = wholeline.to_string(); + cursor.prefix = prefix.to_string(); + + // Currently best index is not implemented so the correct arg parsing is not done here + if !cursor.line.is_empty() && cursor.prefix.is_empty() { + let mut i = cursor.line.len(); + while let Some(ch) = cursor.line.chars().next() { + if i > 0 && (ch.is_alphanumeric() || ch == '_') { + i -= 1; + } else { + break; + } + } + if cursor.line.len() - i > 0 { + // TODO see if need to inclusive range + cursor.prefix = cursor.line[..i].to_string(); + } + } + + cursor.rowid = 0; + cursor.phase = CompletionPhase::Keywords; + + Self::next(cursor) + } +} + +/// The cursor for iterating over the completions +#[derive(Debug, Default)] +struct CompletionCursor { + line: String, + prefix: String, + curr_row: String, + rowid: i64, + phase: CompletionPhase, + inter_phase_counter: usize, + // stmt: Statement + // conn: Connection +} + +impl CompletionCursor { + fn reset(&mut self) { + self.line.clear(); + self.prefix.clear(); + self.inter_phase_counter = 0; + } +} + +impl VTabCursor for CompletionCursor { + type Error = ResultCode; + + fn next(&mut self) -> ResultCode { + self.rowid += 1; + + while self.phase != CompletionPhase::Eof { + // dbg!(&self.phase, &self.prefix, &self.curr_row); + match self.phase { + CompletionPhase::Keywords => { + if self.inter_phase_counter >= KEYWORDS.len() { + self.curr_row.clear(); + self.phase = CompletionPhase::Eof; + } else { + self.curr_row.clear(); + self.curr_row.push_str(KEYWORDS[self.inter_phase_counter]); + self.inter_phase_counter += 1; + } + } + // TODO implement this when db conn is available + // CompletionPhase::Databases => { + // + // // self.stmt = self.conn.prepare("PRAGMA database_list") + // curr_col = 1; + // next_phase = CompletionPhase::Tables; + // self.phase = CompletionPhase::Eof; // for now skip other phases + // } + _ => { + return ResultCode::EOF; + } + } + if self.prefix.is_empty() { + break; + } + if self.prefix.len() <= self.curr_row.len() + && self.prefix.to_lowercase() == self.curr_row.to_lowercase()[..self.prefix.len()] + { + break; + } + } + if self.phase == CompletionPhase::Eof { + return ResultCode::EOF; + } + ResultCode::OK + } + + fn eof(&self) -> bool { + self.phase == CompletionPhase::Eof + } + + fn column(&self, idx: u32) -> Result { + let val = match idx { + 0 => Value::from_text(self.curr_row.clone()), // COMPLETION_COLUMN_CANDIDATE + 1 => Value::from_text(self.prefix.clone()), // COMPLETION_COLUMN_PREFIX + 2 => Value::from_text(self.line.clone()), // COMPLETION_COLUMN_WHOLELINE + 3 => Value::from_integer(self.phase.clone().into()), // COMPLETION_COLUMN_PHASE + _ => Value::null(), + }; + Ok(val) + } + + fn rowid(&self) -> i64 { + self.rowid + } +} + +#[cfg(test)] +mod tests {}