From fd3335908c9b3eeaec949ad43e2c5879850eb5fe Mon Sep 17 00:00:00 2001 From: pedrocarlo Date: Fri, 4 Apr 2025 13:01:57 -0300 Subject: [PATCH 1/4] basic autocomplete for dot commands --- Cargo.lock | 23 +++++++ cli/Cargo.toml | 17 +++--- cli/helper.rs | 161 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 147 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6e5eab5f..2e7a615c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f5378ea264ad4f82bbc826628b5aad714a75abf6ece087e923010eb937fb6" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + [[package]] name = "clap_derive" version = "4.5.32" @@ -1400,6 +1412,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "is_executable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a1b5bad6f9072935961dfbf1cced2f3d129963d091b6f69f007fe04e758ae2" +dependencies = [ + "winapi", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1644,6 +1665,7 @@ dependencies = [ "anyhow", "cfg-if", "clap", + "clap_complete", "comfy-table", "csv", "ctrlc", @@ -1653,6 +1675,7 @@ dependencies = [ "miette", "nu-ansi-term 0.50.1", "rustyline", + "shlex", "syntect", "tracing", "tracing-subscriber", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2a16f2dd0..ddd44519f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -20,24 +20,26 @@ path = "main.rs" [dependencies] anyhow = "1.0.75" +cfg-if = "1.0.0" clap = { version = "4.5.31", features = ["derive"] } +clap_complete = { version = "=4.5.47", features = ["unstable-dynamic"] } comfy-table = "7.1.4" +csv = "1.3.1" +ctrlc = "3.4.4" dirs = "5.0.1" env_logger = "0.10.1" limbo_core = { path = "../core", default-features = true, features = [ "completion", ] } +miette = { version = "7.4.0", features = ["fancy"] } +nu-ansi-term = "0.50.1" 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" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tracing = "0.1.41" +shlex = "1.3.0" syntect = "5.2.0" -nu-ansi-term = "0.50.1" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } [features] @@ -46,4 +48,3 @@ io_uring = ["limbo_core/io_uring"] [build-dependencies] syntect = "5.2.0" - diff --git a/cli/helper.rs b/cli/helper.rs index 90549dd11..a464c3515 100644 --- a/cli/helper.rs +++ b/cli/helper.rs @@ -1,12 +1,18 @@ -use std::rc::Rc; -use std::sync::Arc; - +use clap::Parser; use limbo_core::{Connection, StepResult}; use nu_ansi_term::{Color, Style}; use rustyline::completion::{extract_word, Completer, Pair}; use rustyline::highlight::Highlighter; use rustyline::hint::HistoryHinter; use rustyline::{Completer, Helper, Hinter, Validator}; +use shlex::Shlex; +use std::cell::RefCell; +use std::marker::PhantomData; +use std::rc::Rc; +use std::sync::Arc; +use std::{ffi::OsString, path::PathBuf, str::FromStr as _}; + +use crate::commands::CommandParser; macro_rules! try_result { ($expr:expr, $err:expr) => { @@ -20,7 +26,7 @@ macro_rules! try_result { #[derive(Helper, Completer, Hinter, Validator)] pub struct LimboHelper { #[rustyline(Completer)] - completer: SqlCompleter, + completer: SqlCompleter, #[rustyline(Hinter)] hinter: HistoryHinter, } @@ -77,57 +83,72 @@ impl Highlighter for LimboHelper { } } -pub struct SqlCompleter { +pub struct SqlCompleter { conn: Rc, io: Arc, + // Has to be a ref cell as Rustyline takes immutable reference to self + // This problem would be solved with Reedline as it uses &mut self for completions + cmd: RefCell, + _cmd_phantom: PhantomData, } -impl SqlCompleter { +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') + Self { + conn, + io, + cmd: C::command().into(), + _cmd_phantom: PhantomData::default(), } - 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( + fn dot_completion( &self, - line: &str, - pos: usize, - _ctx: &rustyline::Context<'_>, - ) -> rustyline::Result<(usize, Vec)> { + mut line: &str, + mut pos: usize, + ) -> rustyline::Result<(usize, Vec)> { + line = &line[1..]; + pos = pos - 1; + + let (prefix_pos, _) = extract_word(line, pos, ESCAPE_CHAR, default_break_chars); + + let args = Shlex::new(line); + let mut args = std::iter::once("".to_owned()) + .chain(args) + .map(OsString::from) + .collect::>(); + if line.ends_with(' ') { + args.push(OsString::new()); + } + let arg_index = args.len() - 1; + // dbg!(&pos, line, &args, arg_index); + + let mut cmd = self.cmd.borrow_mut(); + match clap_complete::engine::complete( + &mut cmd, + args, + arg_index, + PathBuf::from_str(".").ok().as_deref(), + ) { + Ok(candidates) => { + let candidates = candidates + .iter() + .map(|candidate| Pair { + display: candidate.get_value().to_string_lossy().into_owned(), + replacement: candidate.get_value().to_string_lossy().into_owned(), + }) + .collect::>(); + + Ok((prefix_pos + 1, candidates)) + } + Err(e) => { + tracing::error!("Dot completion error: {e}"); + Ok((prefix_pos + 1, Vec::new())) + } + } + } + + fn sql_completion(&self, line: &str, pos: usize) -> 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(); @@ -167,3 +188,51 @@ impl Completer for SqlCompleter { Ok((prefix_pos, candidates)) } } + +// 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)> { + if line.starts_with(".") { + self.dot_completion(line, pos) + } else { + self.sql_completion(line, pos) + } + } +} From 57af9c71ba4bb305eb71c6d7e34a56b43fec66cc Mon Sep 17 00:00:00 2001 From: pedrocarlo Date: Fri, 4 Apr 2025 17:39:09 -0300 Subject: [PATCH 2/4] customize completion candidates for some args --- cli/app.rs | 3 ++- cli/commands/args.rs | 40 +++++++++++++++++++++++++++++++++------- cli/commands/mod.rs | 3 --- cli/helper.rs | 1 + 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/cli/app.rs b/cli/app.rs index f82c587bd..40e187d43 100644 --- a/cli/app.rs +++ b/cli/app.rs @@ -467,7 +467,8 @@ impl<'a> Limbo<'a> { } match CommandParser::try_parse_from(args) { Err(err) => { - let _ = self.write_fmt(format_args!("{err}")); + // Let clap print with Styled Colors instead + let _ = err.print(); } Ok(cmd) => match cmd.command { Command::Exit(args) => { diff --git a/cli/commands/args.rs b/cli/commands/args.rs index e0fd10994..3bb78d0b8 100644 --- a/cli/commands/args.rs +++ b/cli/commands/args.rs @@ -1,6 +1,7 @@ use clap::{Args, ValueEnum}; +use clap_complete::{ArgValueCompleter, CompletionCandidate, PathCompleter}; -use crate::input::OutputMode; +use crate::{input::OutputMode, opcodes_dictionary::OPCODE_DESCRIPTIONS}; #[derive(Debug, Clone, Args)] pub struct ExitArgs { @@ -12,13 +13,17 @@ pub struct ExitArgs { #[derive(Debug, Clone, Args)] pub struct OpenArgs { /// Path to open database + #[arg(add = ArgValueCompleter::new(PathCompleter::file()))] pub path: String, + // TODO see how to have this completed with the output of List Vfs function + // Currently not possible to pass arbitrary /// Name of VFS pub vfs_name: Option, } #[derive(Debug, Clone, Args)] pub struct SchemaArgs { + // TODO depends on PRAGMA table_list for completions /// Table name to visualize schema pub table_name: Option, } @@ -26,6 +31,7 @@ pub struct SchemaArgs { #[derive(Debug, Clone, Args)] pub struct SetOutputArgs { /// File path to send output to + #[arg(add = ArgValueCompleter::new(PathCompleter::file()))] pub path: Option, } @@ -35,15 +41,40 @@ pub struct OutputModeArgs { pub mode: OutputMode, } +fn opcodes_completer(current: &std::ffi::OsStr) -> Vec { + let mut completions = vec![]; + + let Some(current) = current.to_str() else { + return completions; + }; + + let current = current.to_lowercase(); + + let opcodes = &OPCODE_DESCRIPTIONS; + + for op in opcodes { + // TODO if someone know how to do prefix_match with case insensitve in Rust + // without converting the String to lowercase first, please fix this. + let op_name = op.name.to_ascii_lowercase(); + if op_name.starts_with(¤t) { + completions.push(CompletionCandidate::new(op.name).help(Some(op.description.into()))); + } + } + + completions +} + #[derive(Debug, Clone, Args)] pub struct OpcodesArgs { /// Opcode to display description + #[arg(add = ArgValueCompleter::new(opcodes_completer))] pub opcode: Option, } #[derive(Debug, Clone, Args)] pub struct CwdArgs { /// Target directory + #[arg(add = ArgValueCompleter::new(PathCompleter::dir()))] pub directory: String, } @@ -72,11 +103,6 @@ pub struct TablesArgs { #[derive(Debug, Clone, Args)] pub struct LoadExtensionArgs { /// Path to extension file - pub path: String, -} - -#[derive(Debug, Clone, Args)] -pub struct ListVfsArgs { - /// Path to extension file + #[arg(add = ArgValueCompleter::new(PathCompleter::file()))] pub path: String, } diff --git a/cli/commands/mod.rs b/cli/commands/mod.rs index 33261a860..757cee530 100644 --- a/cli/commands/mod.rs +++ b/cli/commands/mod.rs @@ -35,9 +35,6 @@ pub enum Command { /// Open a database file #[command(display_name = ".open")] Open(OpenArgs), - /// Print this message or the help of the given subcommand(s) - // #[command(display_name = ".help")] - // Help, /// Display schema for a table #[command(display_name = ".schema")] Schema(SchemaArgs), diff --git a/cli/helper.rs b/cli/helper.rs index a464c3515..f0d396ae0 100644 --- a/cli/helper.rs +++ b/cli/helper.rs @@ -107,6 +107,7 @@ impl SqlCompleter { mut line: &str, mut pos: usize, ) -> rustyline::Result<(usize, Vec)> { + // TODO maybe check to see if the line is empty and then just output the command names line = &line[1..]; pos = pos - 1; From d5fa37ab66d3b0e91b0bdb85e2a9b374b2378d5e Mon Sep 17 00:00:00 2001 From: pedrocarlo Date: Fri, 4 Apr 2025 17:44:34 -0300 Subject: [PATCH 3/4] remove error debug --- cli/helper.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/helper.rs b/cli/helper.rs index f0d396ae0..70194234d 100644 --- a/cli/helper.rs +++ b/cli/helper.rs @@ -142,10 +142,7 @@ impl SqlCompleter { Ok((prefix_pos + 1, candidates)) } - Err(e) => { - tracing::error!("Dot completion error: {e}"); - Ok((prefix_pos + 1, Vec::new())) - } + Err(_) => Ok((prefix_pos + 1, Vec::new())), } } From 907794cb0775b75720bc51472cecd5ca6ffc13b9 Mon Sep 17 00:00:00 2001 From: pedrocarlo Date: Fri, 4 Apr 2025 19:04:42 -0300 Subject: [PATCH 4/4] add path completion for .import --- cli/commands/import.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/commands/import.rs b/cli/commands/import.rs index df33500dd..38ec5df45 100644 --- a/cli/commands/import.rs +++ b/cli/commands/import.rs @@ -1,4 +1,5 @@ use clap::Args; +use clap_complete::{ArgValueCompleter, PathCompleter}; use limbo_core::Connection; use std::{fs::File, io::Write, path::PathBuf, rc::Rc, sync::Arc}; @@ -13,6 +14,7 @@ pub struct ImportArgs { /// Skip the first N rows of input #[arg(long, default_value = "0")] skip: u64, + #[arg(add = ArgValueCompleter::new(PathCompleter::file()))] file: PathBuf, table: String, }