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