diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c42cecde..adcb30ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ### Added +- `ggsql repl`, an interactive session: SQL statements print as tables and, + with the `window` feature, queries with a `VISUALISE` clause draw in a + persistent native window that updates with each new plot. Statements end + with `;`; the reader stays open for the whole session, so tables created by + one statement are visible to the next. `:tables` lists the session's + tables and `:schema ` lists a table's columns and their types. - The Positron extension now registers a ggsql data importer, so dragging a csv/tsv/parquet/json file into Positron offers to generate the ggsql code that loads it into a table, including any filters and sorts shown in the diff --git a/Cargo.lock b/Cargo.lock index 3cdb9db58..d979f7f2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,7 +43,7 @@ dependencies = [ "regex", "toml", "windows-registry", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -191,7 +191,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -202,7 +202,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -800,6 +800,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "clipper2-rust" version = "1.1.0" @@ -817,7 +826,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1321,6 +1330,12 @@ dependencies = [ "log", ] +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "equivalent" version = "1.0.2" @@ -1334,9 +1349,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -1773,6 +1794,7 @@ dependencies = [ "ggsql", "png", "regex", + "rustyline", "serde_json", "termimad", "ureq", @@ -2007,8 +2029,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755ec8c5f3f62917c498d1fdbc6704dad9432c5f523696337895601342aa8c43" +source = "git+https://github.com/posit-dev/hephaestus.git?branch=event-comm#df27b4087a06971d6d6ff344cccd229e13b0d80d" dependencies = [ "bytemuck", "clipper2-rust", @@ -2060,6 +2081,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -2957,13 +2987,34 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3883,6 +3934,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -4272,7 +4333,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4317,6 +4378,27 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "18.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "clipboard-win", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.2.2", + "utf8parse", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.23" @@ -4629,7 +4711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4816,7 +4898,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4825,7 +4907,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5995,7 +6077,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 457247270..9890cac30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,3 +95,7 @@ panic = "abort" [patch.crates-io] sqlite-wasm-rs = { git = "https://github.com/ggsql-dev/sqlite-wasm-rs.git", branch = "loadable-extensions" } +# Temporary, for the REPL plot window: builds against the hephaestus branch +# carrying the cross-thread window message API (posit-dev/hephaestus#49). +# Remove once a hephaestus release containing it is published. +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", branch = "event-comm" } diff --git a/doc/get_started/tooling/cli.qmd b/doc/get_started/tooling/cli.qmd index 4341af3d7..19449d82c 100644 --- a/doc/get_started/tooling/cli.qmd +++ b/doc/get_started/tooling/cli.qmd @@ -249,6 +249,24 @@ Resizing the window re-lays-out the plot rather than stretching it, so labels an Like the raster writers, the viewer renders through the GPU and is not in every build. The subcommand exists either way and says what would enable it. +## Interactive sessions + +`ggsql repl` starts an interactive session in the terminal: + +```bash +ggsql repl +``` + +Statements end with `;`. A plain SQL statement prints its table; a query with a `VISUALISE` clause draws its plot in a native window that opens when the session starts and updates in place with each new plot. The database connection stays open for the whole session, so a table created by one statement is visible to the next: + +``` +ggsql> CREATE TABLE penguins AS SELECT * FROM 'penguins.csv'; +ggsql> SELECT species, COUNT(*) AS n FROM penguins GROUP BY species; +ggsql> SELECT * FROM penguins VISUALISE species AS fill DRAW bar; +``` + +History is kept across sessions, Ctrl-C abandons the statement being typed rather than the session, and `:quit` (or Ctrl-D) leaves — closing the window ends the session too. `:tables` lists the tables in the session's schema and `:schema
` lists a table's columns and their types; `:help` lists all the commands. The session takes the same flags as `ggsql view`: `--reader` and `--cache` for the data source, and `-D` for the window's `width`, `height`, `background` and `title`. Like the viewer, the plot window renders through the GPU and is not in every build; the subcommand exists either way and says what would enable it. + ## Documentation The ggsql CLI has built-in documentation for ggsql syntax and usage. Run `ggsql docs` for an overview of available documentation topics, and `ggsql docs [topic]` to read about a specific topic. diff --git a/ggsql-cli/Cargo.toml b/ggsql-cli/Cargo.toml index 09de24518..a805f29d9 100644 --- a/ggsql-cli/Cargo.toml +++ b/ggsql-cli/Cargo.toml @@ -39,6 +39,7 @@ serde_json.workspace = true # Regex (used by `docs` markdown post-processing) regex.workspace = true +rustyline = "18.0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Terminal markdown rendering for `ggsql docs` (native targets only; diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index 9397f6a11..ab7d1ce93 100644 --- a/ggsql-cli/src/main.rs +++ b/ggsql-cli/src/main.rs @@ -13,6 +13,11 @@ use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use writers::{Output, WriterInfo}; +// The REPL only runs with the plot window, so it is compiled only there — +// and under `cfg(test)`, so its statement-logic tests run in every build. +#[cfg(any(feature = "window", test))] +mod repl; +mod table; mod writers; mod docs { @@ -225,6 +230,20 @@ pub enum Commands { view: ViewArgs, }, + /// Start an interactive session (a REPL) + /// + /// SQL statements print as tables; queries with a VISUALISE clause draw + /// their plot in a window that updates with each new plot. Statements end + /// with `;`. The reader stays open for the whole session, so tables + /// created in one statement are visible to the next. `:tables` lists the + /// session's tables and `:schema
` describes one. + /// + /// Requires the `window` feature and a working GPU adapter. + Repl { + #[command(flatten)] + view: ViewArgs, + }, + /// Parse a query and show the AST (for debugging) Parse { /// The ggsql query to parse @@ -319,6 +338,10 @@ fn main() -> anyhow::Result<()> { cmd_view(query, &view); } + Commands::Repl { view } => { + cmd_repl(&view); + } + Commands::Parse { query, format } => { cmd_parse(query, format); } @@ -624,6 +647,87 @@ fn cmd_view(query: String, args: &ViewArgs) { } } +/// Run an interactive session. +/// +/// The reader is opened once and lives for the whole session, which is what +/// makes one statement's tables visible to the next. The plot window's event +/// loop owns the main thread (the platform requires it) and the REPL loop +/// runs on a worker thread; finished `Spec`s cross to the window through its +/// message channel. +/// +/// The subcommand exists whether or not the feature does, so a build without +/// it says what would bring it back rather than dropping the command. +fn cmd_repl(args: &ViewArgs) { + #[cfg(feature = "window")] + { + use ggsql::writer::{PlotViewer, PlotWindowHandle}; + + let reader = + open_reader(&args.source.reader, args.source.cache.as_deref()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + if args.verbose { + eprintln!("Reader: {}", args.source.reader); + if let Some(ref cache) = args.source.cache { + eprintln!("Cache: {}", cache); + } + } + + let options = WriterOptions::parse(args.viewer_options.clone()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + let viewer = PlotViewer::from_options(&options).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + let window = viewer.launch().unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + struct WindowDisplay(PlotWindowHandle); + impl repl::PlotDisplay for WindowDisplay { + fn show(&self, spec: Spec) -> Result<(), String> { + self.0.show(spec).map_err(|e| e.to_string()) + } + fn is_closed(&self) -> bool { + self.0.is_closed() + } + fn shutdown(&self) { + self.0.shutdown(); + } + } + + let display = WindowDisplay(window.plot_handle()); + let verbose = args.verbose; + // The REPL thread is not joined: when the window closes, `run` + // returns below and the process exits, taking the thread blocked in + // readline with it. + std::thread::spawn(move || { + if let Err(e) = repl::run(reader.as_ref(), &display, verbose) { + eprintln!("REPL failed: {}", e); + repl::PlotDisplay::shutdown(&display); + } + }); + + // Blocks on the main thread until the window closes. + if let Err(e) = window.run() { + eprintln!("{}", e); + std::process::exit(1); + } + } + + #[cfg(not(feature = "window"))] + { + let _ = args; + eprintln!("The plot viewer is not compiled in. Rebuild with --features window"); + std::process::exit(1); + } +} + fn cmd_parse(query: String, format: String) { println!("Parsing query: {}", query); println!("Format: {}", format); @@ -699,63 +803,10 @@ fn print_table_fallback( max_rows: usize, output: Option<&std::path::Path>, ) { - let source_tree = match parser::SourceTree::new(query) { - Ok(st) => st, - Err(e) => { - eprintln!("Failed to parse query: {}", e); - std::process::exit(1); - } - }; - - let sql_part = source_tree.extract_sql().unwrap_or_default(); - - let data = reader.execute_sql(&sql_part); - if let Err(e) = data { - eprintln!("Failed to execute SQL query: {}", e); - std::process::exit(1) - } - let data = data.unwrap(); - - let nrow = data.height().min(max_rows); - let ncol = data.width(); - let colnames = data.get_column_names(); - - // We add an extra 'row' for the column names - let mut rows: Vec = vec![String::from(""); nrow + 1]; - - let columns = data.get_columns(); - for (col_id, (col_name, column_data)) in colnames.iter().zip(columns.iter()).enumerate() { - let mut width = col_name.chars().count(); - - // End last column without comma - let suffix = if col_id == ncol - 1 { "" } else { ", " }; - - // Prepopulate formatted column with column name - let mut col_fmt: Vec = vec![format!("{}{}", col_name, suffix)]; - - // Format every cell in column, tracking width - for row_idx in 0..nrow { - let cell = ggsql::array_util::value_to_string(column_data, row_idx); - let cell_fmt = format!("{}{}", cell, suffix); - let nchar = cell_fmt.chars().count(); - if nchar > width { - width = nchar; - } - col_fmt.push(cell_fmt); - } - // Pad strings with spaces - let col_fmt: Vec = col_fmt - .into_iter() - .map(|s| format!("{:width$}", s, width = width)) - .collect(); - - // Push columns to row string - for (row, fmt) in rows.iter_mut().zip(col_fmt.iter()) { - row.push_str(fmt.as_str()); - } - } - - let table = rows.join("\n"); + let table = table::format(query, reader, max_rows).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); match output { None => println!("{}", table), Some(path) => { diff --git a/ggsql-cli/src/repl.rs b/ggsql-cli/src/repl.rs new file mode 100644 index 000000000..ad5bcb211 --- /dev/null +++ b/ggsql-cli/src/repl.rs @@ -0,0 +1,461 @@ +//! The interactive REPL behind `ggsql repl`. +//! +//! The loop itself is a thin rustyline shell; everything with logic in it — +//! statement completeness, classification, dispatch — is a free function so it +//! can be tested without a terminal. + +use ggsql::array_util::new_str_array; +use ggsql::reader::{Reader, Spec}; +use ggsql::validate::validate; +use ggsql::DataFrame; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; + +use crate::table; + +const PROMPT: &str = "ggsql> "; +const CONTINUATION_PROMPT: &str = " ...> "; +const MAX_TABLE_ROWS: usize = 100; + +/// What one submitted input means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Statement { + /// Nothing but whitespace — ignore it. + Empty, + /// Leave the session. + Quit, + /// List the available commands. + Help, + /// List the tables in the session's catalog and schema. + Tables, + /// List the columns of the named table. + Schema(String), + /// A `:` command the REPL doesn't know. + Unknown(String), + /// A ggsql query, with the terminating `;` stripped. + Query(String), +} + +/// Where a finished plot goes. The window feature supplies the real +/// implementation; without it, and in tests, a stub explains the situation. +pub trait PlotDisplay { + fn show(&self, spec: Spec) -> Result<(), String>; + + /// Whether the user has closed the plot window. Always `false` for + /// displays that can't close. + fn is_closed(&self) -> bool { + false + } + + /// Tell the display the session is over, so it can close too. + fn shutdown(&self) {} +} + +/// Whether the accumulated input is ready to run. +/// +/// Meta-commands are complete on one line. Queries are complete when brackets +/// balance, no string is open, and the statement ends with `;` — matching how +/// SQL clients decide, and avoiding guesses about where a VISUALISE clause +/// ends. +pub fn is_complete(input: &str) -> bool { + let trimmed = input.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.starts_with(':') { + return true; + } + if !trimmed.ends_with(';') { + return false; + } + + let mut paren_depth = 0i32; + let mut bracket_depth = 0i32; + let mut in_string: Option = None; + let mut chars = trimmed.chars().peekable(); + + while let Some(c) = chars.next() { + match in_string { + Some(quote) if c == quote => { + // SQL escapes a quote inside a string by doubling it. + if chars.peek() == Some("e) { + chars.next(); + } else { + in_string = None; + } + } + Some(_) => {} + None => match c { + '\'' | '"' => in_string = Some(c), + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + _ => {} + }, + } + } + + in_string.is_none() && paren_depth == 0 && bracket_depth == 0 +} + +/// Turn complete input into what to do with it. +pub fn classify(input: &str) -> Statement { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Statement::Empty; + } + if let Some(command) = trimmed.strip_prefix(':') { + let name = command.split_whitespace().next().unwrap_or(""); + let argument = command[name.len()..].trim(); + return match name { + "quit" | "exit" | "q" => Statement::Quit, + "help" | "h" | "?" => Statement::Help, + "tables" | "t" => Statement::Tables, + "schema" | "describe" | "d" => Statement::Schema(argument.to_string()), + _ => Statement::Unknown(command.to_string()), + }; + } + // `is_complete` guarantees the trailing `;`; all that remains is to strip + // it so the parser never sees it. + let query = trimmed.trim_end_matches(';').trim().to_string(); + if query.is_empty() { + Statement::Empty + } else { + Statement::Query(query) + } +} + +/// Run one query, printing the table or handing the plot to `plots`. +/// +/// Errors are reported, not propagated: one bad statement must not end the +/// session. +pub fn execute_query(query: &str, reader: &dyn Reader, plots: &dyn PlotDisplay, verbose: bool) { + let validated = match validate(query) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to validate query: {e}"); + return; + } + }; + if !validated.valid() { + eprintln!("Validation errors:"); + for err in validated.errors() { + eprintln!(" - {}", err.message); + } + return; + } + + if !validated.has_visual() { + match table::format(query, reader, MAX_TABLE_ROWS) { + Ok(table) => println!("{table}"), + Err(e) => eprintln!("{e}"), + } + return; + } + + let spec = match reader.execute(query) { + Ok(spec) => spec, + Err(e) => { + eprintln!("Failed to execute query: {e}"); + return; + } + }; + if verbose { + let metadata = spec.metadata(); + eprintln!("Rows: {}, layers: {}", metadata.rows, metadata.layer_count); + } + if let Err(e) = plots.show(spec) { + eprintln!("{e}"); + } +} + +fn print_help() { + println!("Commands:"); + println!(" :quit, :exit Leave the session (Ctrl-D works too)"); + println!(" :help Show this text"); + println!(" :tables List the tables in the session's schema"); + println!(" :schema
List a table's columns and their types"); + println!(); + println!("Statements end with ';'. A plain SQL statement prints as a table;"); + println!("a query with a VISUALISE clause draws in the plot window."); +} + +/// The catalog/schema pair the introspection commands look at, resolved once +/// when the session starts: the reader's first catalog, and its `main` (or +/// `public`) schema when it has one. +pub struct Scope { + catalog: String, + schema: String, +} + +pub fn resolve_scope(reader: &dyn Reader) -> Scope { + let catalog = reader + .list_catalogs() + .ok() + .and_then(|catalogs| catalogs.into_iter().next()) + .unwrap_or_default(); + let schema = reader + .list_schemas(&catalog) + .ok() + .and_then(|schemas| { + schemas + .iter() + .find(|name| *name == "main" || *name == "public") + .cloned() + .or_else(|| schemas.into_iter().next()) + }) + .unwrap_or_default(); + Scope { catalog, schema } +} + +/// The `:tables` output: the schema's tables and views as a formatted table. +pub fn tables_listing(reader: &dyn Reader, scope: &Scope) -> Result { + let tables = reader + .list_tables(&scope.catalog, &scope.schema) + .map_err(|e| format!("Failed to list tables: {e}"))?; + // Internal `__ggsql_*` tables (builtin datasets, cache memos) are an + // implementation detail, not part of the user's schema. + let tables: Vec<_> = tables + .into_iter() + .filter(|table| !table.name.starts_with("__ggsql_")) + .collect(); + if tables.is_empty() { + return Ok("No tables.".to_string()); + } + let names: Vec> = tables + .iter() + .map(|table| Some(table.name.as_str())) + .collect(); + let types: Vec> = tables + .iter() + .map(|table| Some(table.table_type.as_str())) + .collect(); + let frame = DataFrame::new(vec![ + ("table_name", new_str_array(names)), + ("table_type", new_str_array(types)), + ]) + .map_err(|e| e.to_string())?; + Ok(table::format_frame(&frame, usize::MAX)) +} + +/// The `:schema
` output: the table's columns and their types. +pub fn schema_listing( + reader: &dyn Reader, + scope: &Scope, + table_name: &str, +) -> Result { + let columns = reader + .list_columns(&scope.catalog, &scope.schema, table_name) + .map_err(|e| format!("Failed to describe '{table_name}': {e}"))?; + if columns.is_empty() { + return Err(format!("No table named '{table_name}'.")); + } + let names: Vec> = columns + .iter() + .map(|column| Some(column.name.as_str())) + .collect(); + let types: Vec> = columns + .iter() + .map(|column| Some(column.data_type.as_str())) + .collect(); + let frame = DataFrame::new(vec![ + ("column_name", new_str_array(names)), + ("data_type", new_str_array(types)), + ]) + .map_err(|e| e.to_string())?; + Ok(table::format_frame(&frame, usize::MAX)) +} + +fn history_path() -> Option { + #[allow(deprecated)] + std::env::home_dir().map(|home| home.join(".ggsql_history")) +} + +/// Run the read-eval-print loop until the user leaves. +pub fn run(reader: &dyn Reader, plots: &dyn PlotDisplay, verbose: bool) -> rustyline::Result<()> { + let mut editor = DefaultEditor::new()?; + let history = history_path(); + if let Some(path) = &history { + let _ = editor.load_history(path); + } + + println!("ggsql REPL — end statements with ';'. :help for help, :quit to leave."); + + let scope = resolve_scope(reader); + + let mut buffer = String::new(); + loop { + let prompt = if buffer.trim().is_empty() { + PROMPT + } else { + CONTINUATION_PROMPT + }; + match editor.readline(prompt) { + Ok(line) => { + buffer.push_str(&line); + buffer.push('\n'); + if !is_complete(&buffer) { + continue; + } + if let Err(e) = editor.add_history_entry(buffer.trim()) { + eprintln!("warning: could not record history: {e}"); + } + let statement = classify(&buffer); + buffer.clear(); + match statement { + Statement::Empty => {} + Statement::Quit => break, + Statement::Help => print_help(), + Statement::Tables => match tables_listing(reader, &scope) { + Ok(listing) => println!("{listing}"), + Err(e) => eprintln!("{e}"), + }, + Statement::Schema(table_name) if table_name.is_empty() => { + eprintln!("usage: :schema
") + } + Statement::Schema(table_name) => { + match schema_listing(reader, &scope, &table_name) { + Ok(listing) => println!("{listing}"), + Err(e) => eprintln!("{e}"), + } + } + Statement::Unknown(command) => { + eprintln!("Unknown command ':{command}'. :help lists the commands.") + } + Statement::Query(query) => execute_query(&query, reader, plots, verbose), + } + // The window closing is noticed here rather than instantly: + // rustyline is blocked on input, so the session ends after + // the next submitted line. + if plots.is_closed() { + println!("Plot window closed; ending the session."); + break; + } + } + // Ctrl-C abandons the current statement, not the session. + Err(ReadlineError::Interrupted) => { + buffer.clear(); + println!("^C"); + } + Err(ReadlineError::Eof) => break, + Err(e) => return Err(e), + } + } + + // The session is over; close the window if there is one. + plots.shutdown(); + + if let Some(path) = &history { + let _ = editor.save_history(path); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_is_never_complete() { + assert!(!is_complete("")); + assert!(!is_complete(" \n ")); + assert_eq!(classify(" \n "), Statement::Empty); + } + + #[test] + fn queries_need_a_terminating_semicolon() { + assert!(!is_complete("SELECT 1")); + assert!(is_complete("SELECT 1;")); + assert!(!is_complete("SELECT 1; extra")); + } + + #[test] + fn brackets_and_strings_delay_completeness() { + assert!(!is_complete("SELECT (1 + 2;")); + assert!(is_complete("SELECT (1 + 2);")); + assert!(!is_complete("SELECT 'oops;")); + // A semicolon inside a string doesn't count as the terminator — but + // since the string is still open the statement is incomplete anyway, + // and once closed it needs a real `;`. + assert!(!is_complete("SELECT ';'")); + assert!(is_complete("SELECT ';';")); + } + + #[test] + fn doubled_quotes_stay_inside_the_string() { + assert!(!is_complete("SELECT 'it''s")); + assert!(is_complete("SELECT 'it''s';")); + } + + #[test] + fn meta_commands_are_complete_on_one_line() { + assert!(is_complete(":quit")); + assert_eq!(classify(":quit"), Statement::Quit); + assert_eq!(classify(":q"), Statement::Quit); + assert_eq!(classify(":exit"), Statement::Quit); + assert_eq!(classify(":help"), Statement::Help); + assert_eq!(classify(":h"), Statement::Help); + assert_eq!(classify(":bogus"), Statement::Unknown("bogus".to_string())); + } + + #[test] + fn introspection_commands_parse_with_aliases() { + assert_eq!(classify(":tables"), Statement::Tables); + assert_eq!(classify(":t"), Statement::Tables); + assert_eq!( + classify(":schema my_table"), + Statement::Schema("my_table".to_string()) + ); + assert_eq!( + classify(":describe my_table"), + Statement::Schema("my_table".to_string()) + ); + assert_eq!( + classify(":d my_table"), + Statement::Schema("my_table".to_string()) + ); + // No argument is still the command; the REPL prints a usage hint. + assert_eq!(classify(":schema"), Statement::Schema(String::new())); + // Extra whitespace around the argument is fine. + assert_eq!( + classify(":schema spaced "), + Statement::Schema("spaced".to_string()) + ); + } + + #[test] + fn listings_come_from_the_reader() { + let reader = + ggsql::reader::DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader + .execute_sql("CREATE TABLE animals (name VARCHAR, legs INTEGER)") + .unwrap(); + let scope = resolve_scope(&reader); + + let tables = tables_listing(&reader, &scope).unwrap(); + assert!(tables.contains("animals")); + + let schema = schema_listing(&reader, &scope, "animals").unwrap(); + assert!(schema.contains("name")); + assert!(schema.contains("VARCHAR")); + assert!(schema.contains("legs")); + + assert!(schema_listing(&reader, &scope, "nope").is_err()); + } + + #[test] + fn classify_strips_the_terminator() { + assert_eq!( + classify("SELECT 1;\n"), + Statement::Query("SELECT 1".to_string()) + ); + assert_eq!( + classify("SELECT * FROM t VISUALISE DRAW point USING x AS x, y AS y;"), + Statement::Query( + "SELECT * FROM t VISUALISE DRAW point USING x AS x, y AS y".to_string() + ) + ); + assert_eq!(classify(";"), Statement::Empty); + } +} diff --git a/ggsql-cli/src/table.rs b/ggsql-cli/src/table.rs new file mode 100644 index 000000000..4dd9de344 --- /dev/null +++ b/ggsql-cli/src/table.rs @@ -0,0 +1,119 @@ +//! Aligned, CSV-like table formatting for query results with nothing to draw. +//! +//! Shared by `exec`/`run` (the no-VISUALISE fallback) and the REPL, so a plain +//! SQL statement looks the same however it was submitted. A result truncated +//! at `max_rows` says how many rows were left out. + +use ggsql::parser; +use ggsql::reader::Reader; +use ggsql::DataFrame; + +/// Run `query`'s SQL part and format up to `max_rows` rows as an aligned, +/// CSV-like table. +pub fn format(query: &str, reader: &dyn Reader, max_rows: usize) -> Result { + let source_tree = + parser::SourceTree::new(query).map_err(|e| format!("Failed to parse query: {e}"))?; + + let sql_part = source_tree.extract_sql().unwrap_or_default(); + + let data = reader + .execute_sql(&sql_part) + .map_err(|e| format!("Failed to execute SQL query: {e}"))?; + + Ok(format_frame(&data, max_rows)) +} + +/// Format up to `max_rows` of `data` as an aligned, CSV-like table. +pub(crate) fn format_frame(data: &DataFrame, max_rows: usize) -> String { + let nrow = data.height().min(max_rows); + let ncol = data.width(); + let colnames = data.get_column_names(); + + // We add an extra 'row' for the column names + let mut rows: Vec = vec![String::from(""); nrow + 1]; + + let columns = data.get_columns(); + for (col_id, (col_name, column_data)) in colnames.iter().zip(columns.iter()).enumerate() { + let mut width = col_name.chars().count(); + + // End last column without comma + let suffix = if col_id == ncol - 1 { "" } else { ", " }; + + // Prepopulate formatted column with column name + let mut col_fmt: Vec = vec![format!("{}{}", col_name, suffix)]; + + // Format every cell in column, tracking width + for row_idx in 0..nrow { + let cell = ggsql::array_util::value_to_string(column_data, row_idx); + let cell_fmt = format!("{}{}", cell, suffix); + let nchar = cell_fmt.chars().count(); + if nchar > width { + width = nchar; + } + col_fmt.push(cell_fmt); + } + // Pad strings with spaces + let col_fmt: Vec = col_fmt + .into_iter() + .map(|s| format!("{:width$}", s, width = width)) + .collect(); + + // Push columns to row string + for (row, fmt) in rows.iter_mut().zip(col_fmt.iter()) { + row.push_str(fmt.as_str()); + } + } + + let hidden = data.height() - nrow; + if hidden > 0 { + rows.push(format!( + "… {hidden} more row{} not shown", + if hidden == 1 { "" } else { "s" } + )); + } + + rows.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use ggsql::array_util::{new_i64_array, new_str_array}; + use ggsql::DataFrame; + + fn frame() -> DataFrame { + DataFrame::new(vec![ + ( + "name", + new_str_array(vec![Some("alpha"), Some("b"), Some("gamma")]), + ), + ("count", new_i64_array(vec![Some(10), Some(2), Some(300)])), + ]) + .unwrap() + } + + #[test] + fn rows_and_header_are_aligned() { + let out = format_frame(&frame(), 100); + assert_eq!( + out, + "name, count\n\ + alpha, 10 \n\ + b, 2 \n\ + gamma, 300 " + ); + } + + #[test] + fn truncated_results_have_a_footer() { + let out = format_frame(&frame(), 2); + assert_eq!(out.lines().last().unwrap(), "… 1 more row not shown"); + + let out = format_frame(&frame(), 1); + assert_eq!(out.lines().last().unwrap(), "… 2 more rows not shown"); + + // Nothing truncated, no footer. + let out = format_frame(&frame(), 100); + assert!(!out.contains("not shown")); + } +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 2925c320c..f75a70b5e 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -86,7 +86,7 @@ pub use tiff::{TiffCompression, TiffWriter}; #[cfg(feature = "webp")] pub use webp::WebpWriter; #[cfg(feature = "window")] -pub use window::{PlotViewer, VIEWER_OPTIONS}; +pub use window::{PlotViewer, PlotWindowHandle, ReplWindow, VIEWER_OPTIONS}; // Re-exported so a caller can name the setting without depending on the // renderer crate; the variants are the format's own vocabulary. diff --git a/src/writer/hephaestus/window.rs b/src/writer/hephaestus/window.rs index 8d94688e6..c0d9a4ecd 100644 --- a/src/writer/hephaestus/window.rs +++ b/src/writer/hephaestus/window.rs @@ -6,9 +6,13 @@ use std::cell::Cell; use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use hephaestus::plot::PlotComposition; -use hephaestus::window::{self, Event, EventCtx, Frame, WindowApp, WindowConfig}; +use hephaestus::window::{ + self, Event, EventCtx, Frame, MessageApp, WindowApp, WindowConfig, WindowHandle, WindowSender, +}; use super::canvas::{parse_background, whole_pixels, Dimensions}; use crate::reader::Spec; @@ -151,6 +155,28 @@ impl PlotViewer { Ok(closing.get()) } + + /// Prepare a persistent window that shows each plot it is sent. + /// + /// Unlike [`show`](Self::show), nothing blocks yet: the returned + /// [`ReplWindow`] hands out a [`PlotWindowHandle`] for another thread to + /// send plots to, and its [`run`](ReplWindow::run) blocks on the main + /// thread until the window closes. That is the shape an interactive + /// session needs — queries execute on its thread, the window's event loop + /// keeps the main thread, and a winit event loop may only be created once + /// per process, so the window is opened once and reused. + pub fn launch(&self) -> Result { + let handle = WindowHandle::new() + .map_err(|e| GgsqlError::WriterError(format!("the plot viewer failed: {e}")))?; + let config = WindowConfig::new(self.title.clone()) + .size(self.width, self.height) + .background(self.background); + Ok(ReplWindow { + handle, + config, + closed: Arc::new(AtomicBool::new(false)), + }) + } } impl Default for PlotViewer { @@ -170,6 +196,125 @@ struct SpecApp { closing: Rc>, } +/// What the session thread sends the window: a new plot to show, or the end +/// of the session. +/// +/// The `Spec` crosses rather than a `PlotComposition` so the window thread +/// does the composing — the plot types are single-threaded by design, and the +/// session thread's `Spec` is plain owned data. +enum ReplRequest { + Plot(Box), + Shutdown, +} + +/// A persistent plot window for an interactive session, not yet running. +/// +/// Created by [`PlotViewer::launch`]. Hand a [`PlotWindowHandle`] to the +/// session thread with [`plot_handle`](Self::plot_handle), then call +/// [`run`](Self::run) on the main thread. +pub struct ReplWindow { + handle: WindowHandle, + config: WindowConfig, + closed: Arc, +} + +impl ReplWindow { + /// The handle the session thread uses to reach the window. + pub fn plot_handle(&self) -> PlotWindowHandle { + PlotWindowHandle { + sender: self.handle.sender(), + closed: Arc::clone(&self.closed), + } + } + + /// Open the window and block until it closes. + /// + /// Must be called from the main thread, like [`PlotViewer::show`]. The + /// window starts empty; the first plot arrives when the session thread + /// sends one. + pub fn run(self) -> Result<()> { + let app = ReplApp { + current: None, + closed: Arc::clone(&self.closed), + }; + self.handle + .run(self.config, app) + .map_err(|e| GgsqlError::WriterError(format!("the plot viewer failed: {e}"))) + } +} + +/// The session thread's way to reach the plot window. +/// +/// `Send` and cheap to clone. `is_closed` flips when the user closes the +/// window, so the session can notice and end rather than keep accepting +/// queries nobody will see. +#[derive(Clone)] +pub struct PlotWindowHandle { + sender: WindowSender, + closed: Arc, +} + +impl PlotWindowHandle { + /// Show a plot in the window, replacing whatever it currently shows. + pub fn show(&self, spec: Spec) -> Result<()> { + self.sender + .send(ReplRequest::Plot(Box::new(spec))) + .map_err(|e| GgsqlError::WriterError(format!("could not reach the plot window: {e}"))) + } + + /// Ask the window to close, e.g. because the session ended. + /// + /// Ignores a dead channel: the window being gone already is the goal. + pub fn shutdown(&self) { + let _ = self.sender.send(ReplRequest::Shutdown); + } + + /// Whether the user has closed the window. + pub fn is_closed(&self) -> bool { + self.closed.load(Ordering::Relaxed) + } +} + +/// The window-side app for an interactive session: shows the latest plot it +/// was sent, records the window closing so the session thread can notice. +struct ReplApp { + current: Option, + closed: Arc, +} + +impl WindowApp for ReplApp { + fn draw(&mut self, frame: &mut Frame<'_>) { + let Some(view) = self.current.as_mut() else { + // No plot yet: the frame stays at the background color. + return; + }; + let (scene, size, dpi) = frame.parts(); + view.render(scene, size, dpi); + } + + fn event(&mut self, ctx: &mut EventCtx<'_>, event: Event) { + if matches!(event, Event::CloseRequested) { + self.closed.store(true, Ordering::Relaxed); + ctx.exit(); + } + } +} + +impl MessageApp for ReplApp { + fn message(&mut self, ctx: &mut EventCtx<'_>, message: ReplRequest) { + match message { + // hephaestus requests a redraw after every message, so a new + // composition is on screen at the next frame with nothing more + // to do here. + ReplRequest::Plot(spec) => match super::compose::prepare(spec.plot(), spec.data()) { + Ok(view) => self.current = Some(view), + Err(e) => eprintln!("Failed to compose the plot: {e}"), + }, + ReplRequest::Shutdown => ctx.exit(), + } + } +} + impl WindowApp for SpecApp { fn draw(&mut self, frame: &mut Frame<'_>) { // The frame reports its own size and dpi, which is what makes a resize diff --git a/src/writer/mod.rs b/src/writer/mod.rs index a7a154b1b..131515ef0 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -87,7 +87,7 @@ pub use hephaestus::SvgWriter; // Not a writer — it produces no output — but it needs the same composition, so // it lives beside them. See its own docs for why it is not a `Writer` impl. #[cfg(feature = "window")] -pub use hephaestus::{Dimensions, PlotViewer, VIEWER_OPTIONS}; +pub use hephaestus::{Dimensions, PlotViewer, PlotWindowHandle, ReplWindow, VIEWER_OPTIONS}; #[cfg(feature = "png")] pub use hephaestus::{PngCompression, PngWriter}; #[cfg(feature = "tiff")]