Merge 'Shell command completion' from Pedro Muniz

This PR adds tab completion to the CLI app. To achieve that I
implemented a simplified, but similar, Vtable that given a prefix and a
line, it outputs rows of candidates. Currently, it only supports auto
completion for SQLite keywords, but in the future we can autocomplete
database names and columns when `PRAGMA database_list` is implemented.
Also, some work will need to be done for detecting whether some syntax
would be legal in certain scenarios, but I think this is better
delegated to a future PR.

Closes #1050
This commit is contained in:
Pere Diaz Bou
2025-03-05 14:04:30 +01:00
11 changed files with 570 additions and 33 deletions

59
Cargo.lock generated
View File

@@ -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"

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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<AtomicUsize>,
input_buff: String,
opts: Settings,
pub rl: &'a mut DefaultEditor,
pub rl: &'a mut Editor<LimboHelper, DefaultHistory>,
}
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<Self> {
pub fn new(rl: &'a mut rustyline::Editor<LimboHelper, DefaultHistory>) -> anyhow::Result<Self> {
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<AtomicUsize> = Arc::clone(&interrupt_count);

123
cli/helper.rs Normal file
View File

@@ -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<Connection>, io: Arc<dyn limbo_core::IO>) -> Self {
LimboHelper {
completer: SqlCompleter::new(conn, io),
}
}
}
impl Highlighter for LimboHelper {}
pub struct SqlCompleter {
conn: Rc<Connection>,
io: Arc<dyn limbo_core::IO>,
}
impl SqlCompleter {
pub fn new(conn: Rc<Connection>, io: Arc<dyn limbo_core::IO>) -> 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<char> = 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<char> = 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<char> = 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<Self::Candidate>)> {
// 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))
}
}

View File

@@ -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");

View File

@@ -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"

View File

@@ -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(())
}
}

View File

@@ -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"]

View File

@@ -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",
];

View File

@@ -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<i64> 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<Self::VCursor, Self::Error> {
Ok(CompletionCursor::default())
}
fn column(cursor: &Self::VCursor, idx: u32) -> Result<Value, ResultCode> {
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<Value, Self::Error> {
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 {}