From aab9c7417ae558286e756b7d0e50f52cd14615ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:56:42 +0000 Subject: [PATCH 1/9] test: pin multi-line statement entry in the REPL's TTY half (RED) The interactive path only exists when stdin is a terminal, so these drive the real binary under a real PTY (util-linux `script`) and assert on the terminal's final contents rather than on keystroke timing. Four fail today, for the reason #988 describes: a statement currently ends at the first newline, so `SELECT` / `1 AS n;` typed over two lines is two statements and two errors. Two pass as regression guards -- `exit` must still leave without a terminator, and continue-on-error must survive multi-line entry. Pushed on its own so CI is observed red before any implementation lands. Refs #988 skip-changelog: test-only commit; the fragment lands with the implementation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- packages/rust/tests/repl_tty_e2e.rs | 206 ++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/rust/tests/repl_tty_e2e.rs diff --git a/packages/rust/tests/repl_tty_e2e.rs b/packages/rust/tests/repl_tty_e2e.rs new file mode 100644 index 000000000..45df1c138 --- /dev/null +++ b/packages/rust/tests/repl_tty_e2e.rs @@ -0,0 +1,206 @@ +//! End-to-end tests for the interactive (TTY) half of bare `dirsql` (#988). +//! +//! The interactive path only exists when stdin is a terminal, so these drive +//! the real binary under a **real PTY** allocated by util-linux `script`, +//! feeding keystrokes and reading back what the terminal received. Nothing is +//! mocked: real pty, real process, real filesystem, real SQLite. +//! +//! The contract under test is multi-line entry: a statement ends at its +//! semicolon, as decided by SQLite's own tokenizer, so a statement may span +//! as many lines as it needs and a semicolon inside a string literal does not +//! end it. The **piped** path is deliberately untouched by this and stays one +//! statement per line -- `repl_pipe_e2e.rs` pins that. +//! +//! Assertions are on the terminal's final contents rather than on timing, so +//! feeding stdin from a pipe is safe: the pty buffers the keystrokes and the +//! editor consumes them in order. Behaviors that genuinely depend on +//! keystroke *timing* (arrow-key recall, Ctrl+C mid-line) are covered by the +//! curtaincall suite in `packages/python/tests/e2e/`, which auto-waits. +//! +//! Linux-gated: `script -qec` is the util-linux spelling, and the Rust CI test +//! job runs on Linux. + +#![cfg(all(feature = "cli", target_os = "linux"))] + +use std::io::Write; +use std::process::{Command, Stdio}; + +use assert_cmd::cargo::cargo_bin; +use tempfile::TempDir; + +/// What the terminal received, with ANSI escape sequences removed so +/// assertions read against the visible text the editor drew. +struct Session { + screen: String, + code: Option, +} + +/// Run the real binary under a PTY over a fresh directory holding `files`, +/// typing `keys`. +fn repl(keys: &str, files: &[(&str, &str)]) -> Session { + let root = TempDir::new().unwrap(); + for (name, body) in files { + std::fs::write(root.path().join(name), body).unwrap(); + } + let binary = cargo_bin("dirsql"); + + let mut child = Command::new("script") + .arg("-qec") + .arg(binary.to_str().expect("the binary path must be UTF-8")) + .arg("/dev/null") + .current_dir(root.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawning `script` failed -- util-linux must provide it"); + child + .stdin + .take() + .expect("stdin was piped") + .write_all(keys.as_bytes()) + .expect("typing at the pty failed"); + let out = child + .wait_with_output() + .expect("waiting on `script` failed"); + + // `script` merges the child's stdout and stderr onto the pty, which is + // what a user sees; the split is asserted by the piped suite instead. + let mut screen = String::from_utf8_lossy(&out.stdout).into_owned(); + screen.push_str(&String::from_utf8_lossy(&out.stderr)); + Session { + screen: strip_ansi(&screen).replace('\r', ""), + code: out.status.code(), + } +} + +/// Drop CSI / OSC escape sequences the line editor emits to move the cursor +/// and colour the prompt. +fn strip_ansi(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '\u{1b}' { + out.push(ch); + continue; + } + match chars.next() { + // CSI: parameters, then a byte in `@`..`~` ends it. + Some('[') => { + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + // OSC: ends at BEL or ST (`ESC \`). + Some(']') => { + while let Some(next) = chars.next() { + if next == '\u{7}' { + break; + } + if next == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => {} + } + } + out +} + +#[test] +fn a_statement_split_across_lines_runs_as_one() { + // The headline of #988: a statement ends at its semicolon, not at the + // first newline, so it can be laid out over as many lines as it needs. + let session = repl("SELECT\n1 AS n;\nquit\n", &[]); + + assert!( + session.screen.contains(r#"[{"n":1}]"#), + "the two lines ran as one statement, got:\n{}", + session.screen + ); + assert!( + !session.screen.contains("incomplete input"), + "the first line must not be executed on its own, got:\n{}", + session.screen + ); +} + +#[test] +fn a_lone_semicolon_terminates_the_statement_above_it() { + // The terminator does not have to share a line with the statement. + let session = repl("SELECT 1 AS n\n;\nquit\n", &[]); + + assert!( + session.screen.contains(r#"[{"n":1}]"#), + "got:\n{}", + session.screen + ); + assert!( + !session.screen.contains("error"), + "nothing was executed prematurely, got:\n{}", + session.screen + ); +} + +#[test] +fn a_semicolon_inside_a_string_literal_does_not_terminate() { + // The classic trap, and the reason completeness is SQLite's tokenizer's + // call rather than a search for a trailing `;`. + let session = repl("SELECT\n';' AS s;\nquit\n", &[]); + + assert!( + session.screen.contains(r#"[{"s":";"}]"#), + "the literal's semicolon is data, not a terminator, got:\n{}", + session.screen + ); +} + +#[test] +fn an_unterminated_statement_is_never_executed() { + // Reaching EOF mid-statement discards the fragment rather than running + // half of it. + let session = repl("SELECT 1 AS n\n", &[]); + + assert!( + !session.screen.contains(r#"[{"n":1}]"#), + "a fragment must not run, got:\n{}", + session.screen + ); + assert_eq!(session.code, Some(0), "EOF is still a clean exit"); +} + +#[test] +fn exit_words_still_leave_without_a_terminator() { + // `exit` is a REPL word, not SQL, so the completeness rule must let it + // through rather than waiting for a semicolon that never comes. + let session = repl("exit\n", &[]); + + assert_eq!(session.code, Some(0), "got:\n{}", session.screen); + assert!( + !session.screen.contains("error"), + "`exit` is not handed to SQLite, got:\n{}", + session.screen + ); +} + +#[test] +fn a_completed_statement_still_reports_its_own_failure_and_continues() { + // Slice 1's continue-on-error survives multi-line entry. + let session = repl("SELECT nope\nFROM missing;\nSELECT 1 AS n;\nquit\n", &[]); + + assert!( + session.screen.contains("missing"), + "the failure names the table, got:\n{}", + session.screen + ); + assert!( + session.screen.contains(r#"[{"n":1}]"#), + "the next statement still runs, got:\n{}", + session.screen + ); + assert_eq!(session.code, Some(0)); +} From cb75f4ef84a2c6cdd8632d6dbc5251b638f1b0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:30:35 +0000 Subject: [PATCH 2/9] feat(cli): line editing, history, and multi-line entry in the REPL The interactive prompt was a plain line reader: no recall, no editing, one line per statement, and Ctrl+C killed the process. It now runs through reedline, which brings all four at once -- history with the up arrow and Ctrl+R, emacs editing, Ctrl+C that abandons a line instead of the session, and statements that span lines. Three decisions #988 left open, settled: - **reedline over rustyline.** rustyline covers the same four requirements natively and pulls 16 transitive crates to reedline's 52, so the issue's "needs more hand-rolling" premise did not hold; the maintainer's call went to reedline anyway, which is what #988 assumed. - **A statement ends at its semicolon**, as in `sqlite3`, with SQLite's own tokenizer (`sqlite3_complete`) deciding where that is. Hand-rolling the scan is how `SELECT ';'` gets read as two statements. `exit`, `quit` and blank input are exempt: none is SQL, and waiting for a terminator on the one input meant to end the session would trap the user. - **History is global**, at `$XDG_DATA_HOME/dirsql/history` (falling back to `~/.local/share`, then `%APPDATA%`), mirroring `sqlite3`'s single `~/.sqlite_history`: a query worked out in one project is worth recalling in the next. Resolution is a pure function over the environment, so it is unit-tested without touching the filesystem, and a session with nowhere to write still edits and recalls within itself. The pipe path is untouched, as #988 requires: no editor, no history, and still one statement per line with no terminator. A redirected script is not being typed, so there is no continuation prompt for a multi-line rule to hang off. The split is now a `Lines` trait with two implementations rather than a boolean, which also gives the loop a seam to be unit-tested through without a terminal. Testing splits along what each harness can actually see. The byte stream a pipe gives back is not a screen -- the editor repaints by moving the cursor -- so `repl_tty_e2e.rs` (a real pty, answering the editor's cursor-position query itself) asserts only what was ever drawn: multi-line entry, the continuation prompt, the literal-semicolon case. Anything turning on a keystroke landing exactly when the editor is listening was measured flaky at roughly one miss in four there, and lives in the new curtaincall suite, which drives a real VT100 and waits on the screen: history recall, history across sessions, the XDG location, Ctrl+C. curtaincall needs one workaround worth knowing about: pyte formats the cursor-position reply in `report_device_status` but its `write_process_input` hook is an inert stub, so the reply never reaches the process and every session dies with "the cursor position could not be read". Pointing that hook at the pty is the whole fix. Fixes #988 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- Cargo.lock | 385 +++++++++- docs/reference/cli.md | 58 +- .../2026-08-20-curtaincall-dev-dependency.md | 3 + packages/python/pyproject.toml | 5 + .../python/tests/e2e/repl_interactive_test.py | 164 ++++ packages/rust/Cargo.toml | 4 + .../2026-08-20-repl-line-editor.md | 3 + .../2026-08-20-repl-line-editor.md | 67 ++ packages/rust/src/cli/repl.rs | 725 +++++++++++++++--- packages/rust/tests/repl_tty_e2e.rs | 349 ++++++--- 10 files changed, 1556 insertions(+), 207 deletions(-) create mode 100644 packages/python/changelog.d/2026-08-20-curtaincall-dev-dependency.md create mode 100644 packages/python/tests/e2e/repl_interactive_test.py create mode 100644 packages/rust/changelog.d/2026-08-20-repl-line-editor.md create mode 100644 packages/rust/migrations.d/2026-08-20-repl-line-editor.md diff --git a/Cargo.lock b/Cargo.lock index b62d59c84..52e68bdf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -187,6 +196,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "blake3" @@ -253,6 +265,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -332,6 +356,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -378,7 +411,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -399,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", ] [[package]] @@ -427,6 +460,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -449,6 +510,28 @@ version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7ab264ea985f1bd27887d7b21ea2bb046728e05d11909ca138d700c494730db" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -474,6 +557,7 @@ dependencies = [ "libc", "notify", "predicates", + "reedline", "reqwest", "rusqlite", "serde", @@ -516,6 +600,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dtor" version = "0.7.0" @@ -587,6 +680,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1029,6 +1133,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1237,6 +1365,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1306,7 +1443,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.4", ] [[package]] @@ -1332,6 +1469,21 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1401,7 +1553,7 @@ version = "3.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7430702d3cc05cf55f0a2c9e41d991c3b7a53f91e6146a8f282b1bfc7f3fd133" dependencies = [ - "convert_case", + "convert_case 0.11.0", "ctor", "napi-derive-backend", "proc-macro2", @@ -1415,7 +1567,7 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ca5a083f2c9b49a0c7d33ec75c083498849c6fcc46f5497317faa39ea77f5d5" dependencies = [ - "convert_case", + "convert_case 0.11.0", "proc-macro2", "quote", "semver", @@ -1471,6 +1623,15 @@ dependencies = [ "instant", ] +[[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", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1504,6 +1665,29 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1872,6 +2056,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "redox_syscall" version = "0.7.4" @@ -1881,6 +2074,26 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "reedline" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826c1fc22a2b1f14c3f6a80fc3d56adfbfb913d07f170bce4246700de7adfd50" +dependencies = [ + "chrono", + "crossterm", + "fd-lock", + "itertools 0.13.0", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum", + "thiserror", + "unicase", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "regex" version = "1.12.3" @@ -1986,6 +2199,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2107,6 +2329,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sct" version = "0.7.1" @@ -2227,6 +2455,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2275,12 +2524,42 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2593,6 +2872,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2605,6 +2890,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2647,6 +2938,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2830,6 +3130,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2839,12 +3155,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e9dc30e24..811980838 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -55,12 +55,12 @@ pipeline, same flags, same output. See that section for config discovery, dirsql # dirsql 0.2.7 — this directory is a database. # -# SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5 -# SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%' +# SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5; +# SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%'; # # `exit`, `quit`, or Ctrl-D to leave. # -# dirsql> SELECT count(*) AS files FROM './' +# dirsql> SELECT count(*) AS files FROM './'; # [{"files":128}] # dirsql> ``` @@ -79,10 +79,52 @@ rather than re-walking the directory each time, and the live watcher keeps it fresh between them. Files the scan had to skip are named on stderr once, up front. +### Where a statement ends + +At its semicolon — the same rule `sqlite3` uses, and **SQLite's own tokenizer** +decides where that semicolon is. So a statement can be laid out over as many +lines as it needs, and a `;` inside a string literal, a comment, or a +`BEGIN … END` body is not mistaken for the end of one: + +``` +dirsql> SELECT basename, size + ...> FROM './' + ...> ORDER BY size DESC + ...> LIMIT 5; +``` + +The `...>` prompt says the statement is still open. `exit`, `quit`, and a blank +line are not SQL, so they are taken as typed rather than waiting for a +terminator. + +### Editing and history + +The prompt is a full line editor ([reedline](https://github.com/nushell/reedline)), +with the emacs bindings a shell prompt has: + +| Key | Does | +|---|---| +| ↑ / ↓ | Walk back and forth through history. | +| Ctrl-R | Reverse-search history; type to narrow, Enter to accept. | +| Ctrl-A / Ctrl-E | Jump to the start / end of the line. | +| Alt-B / Alt-F | Move back / forward a word. | +| Ctrl-W, Ctrl-K, Ctrl-Y | Kill the previous word, kill to end of line, yank it back. | +| Ctrl-C | Abandon the line and return to a fresh prompt. **Does not exit.** | +| Ctrl-D | Leave. | + +History is kept in one file for every directory — a query worked out in one +project is worth recalling in the next, the same way `sqlite3` keeps a single +`~/.sqlite_history`. It holds the last 1000 statements, at +`$XDG_DATA_HOME/dirsql/history`, falling back to +`~/.local/share/dirsql/history` (`%APPDATA%\dirsql\history` on Windows). If +none of those resolve, history is kept in memory for the session only. + ### Terminal vs. pipe -The prompt and banner appear only when **stdin is a terminal**. From a pipe or -a redirect there is neither, so results arrive alone: +The prompt, banner, editor, and history exist only when **stdin is a terminal**. +From a pipe or a redirect there is none of that, and the terminator rule does +not apply either: a redirected script is not being typed, so there is no +continuation prompt to hang it off. **One statement per line, no `;` needed:** ```bash printf "SELECT 1 AS n\nSELECT 2 AS n\n" | dirsql @@ -92,7 +134,7 @@ printf "SELECT 1 AS n\nSELECT 2 AS n\n" | dirsql dirsql < queries.sql > rows.jsonl ``` -One statement per line, in both modes. Blank lines do nothing. +Blank lines do nothing in either mode. ### Leaving @@ -112,9 +154,9 @@ the one behavioral difference from `dirsql query`, which exits `1` on the first failure: ``` -dirsql> SELECT nope FROM missing +dirsql> SELECT nope FROM missing; dirsql: SQLite error: no such table: missing -dirsql> SELECT 1 AS n +dirsql> SELECT 1 AS n; [{"n":1}] ``` diff --git a/packages/python/changelog.d/2026-08-20-curtaincall-dev-dependency.md b/packages/python/changelog.d/2026-08-20-curtaincall-dev-dependency.md new file mode 100644 index 000000000..f0814d2eb --- /dev/null +++ b/packages/python/changelog.d/2026-08-20-curtaincall-dev-dependency.md @@ -0,0 +1,3 @@ +**Changed** + +- Added [curtaincall](https://github.com/thekevinscott/curtaincall) to the dev dependency group, for the REPL's interactive e2e cases: they need a real PTY behind a VT100 emulator, since the line editor repaints by moving the cursor and its behavior cannot be read off an output stream. **Nothing about the published package changes** — the dev group is excluded from the wheel, so no installed dependency, import, or runtime behavior is affected. It carries a `python_version >= "3.12"` marker against this package's `>=3.10` floor (curtaincall declares `requires-python >=3.12`), and the suite skips itself below that. (#988) diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 4a0115fbd..4635b2255 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -44,6 +44,11 @@ dev = [ "maturin>=1.0", # ty is pre-1.0 -- pin tightly. Re-pin in lockstep with CI. "ty==0.0.42", + # Real PTY + VT100 for the REPL's interactive e2e cases, which need a + # screen rather than an output stream. Marked because curtaincall declares + # `requires-python >=3.12` against this package's >=3.10 floor; the suite + # skips itself below that (thekevinscott/curtaincall#26). + 'curtaincall>=0.3; python_version >= "3.12"', ] [project.scripts] diff --git a/packages/python/tests/e2e/repl_interactive_test.py b/packages/python/tests/e2e/repl_interactive_test.py new file mode 100644 index 000000000..906d3a38d --- /dev/null +++ b/packages/python/tests/e2e/repl_interactive_test.py @@ -0,0 +1,164 @@ +"""E2E tests for the REPL behaviors that need a terminal, not an output stream. + +The `dirsql` REPL's line editor repaints by moving the cursor, so what a pipe +sees is a byte stream rather than a screen: a harness reading it can only guess +when a redraw has finished, and a keystroke sent into the gap after a statement +runs is read by nothing. These tests therefore drive the real console script +through [curtaincall](https://github.com/thekevinscott/curtaincall) -- a real +PTY behind a VT100 emulator -- and wait on the *screen* before each keystroke. + +Nothing is mocked: real console script, real binary, real pty, real editor, +real filesystem, real SQLite. `HOME` and `XDG_DATA_HOME` point into the test's +own tmp tree, so the suite never touches the developer's history file. + +What lives here is what only a screen can answer -- history recall with the up +arrow, history surviving the process, and `Ctrl+C` abandoning a line without +ending the session. The rest of the interactive contract (a statement ending +at its semicolon, the continuation prompt) is asserted from the byte stream by +`packages/rust/tests/repl_tty_e2e.rs`, which runs in CI. + +Skipped below Python 3.12: curtaincall declares `requires-python >=3.12` while +this package's floor is 3.10 (thekevinscott/curtaincall#26). +""" + +from __future__ import annotations + +import os +import shutil + +import pytest + +pytest.importorskip( + "curtaincall", + minversion="0.3", + reason="curtaincall requires Python >= 3.12; this package supports >= 3.10", +) + +from curtaincall import expect + +# curtaincall registers its `terminal` fixture through an entry point, so +# there is nothing to add to `pytest_plugins`. + +_PROMPT = "dirsql>" + + +def _answer_cursor_queries(term) -> None: + """Let the terminal answer the editor's cursor-position queries. + + The line editor asks where the cursor is (a `ESC [ 6 n` Device Status + Report) and gives up when nothing answers, so without this every session + dies with "the cursor position could not be read". pyte already knows the + answer -- `report_device_status` formats the reply -- but its + `write_process_input` hook is an inert stub, so the reply never reaches + the process. Pointing that hook at the pty is the whole of the fix. + """ + term._screen.write_process_input = term.write + + +def _cli() -> str: + """Resolve the `dirsql` console script for this test env.""" + dirsql = shutil.which("dirsql") + assert dirsql is not None, ( + "`dirsql` console script not on PATH -- run `uv run maturin develop`" + ) + return dirsql + + +def describe_the_interactive_repl(): + @pytest.fixture + def home(tmp_path): + """A tmp HOME so history never lands in the developer's real one.""" + data = tmp_path / "data" + data.mkdir() + return data + + @pytest.fixture + def open_repl(terminal, tmp_path, home): + """Open a REPL over an empty directory and wait for its first prompt.""" + + def _open(): + workdir = tmp_path / "tree" + workdir.mkdir(exist_ok=True) + term = terminal( + f"sh -c 'cd {workdir} && exec {_cli()}'", + env={ + **os.environ, + "XDG_DATA_HOME": str(home), + "TERM": "xterm", + }, + ) + _answer_cursor_queries(term) + expect(term.get_by_text(_PROMPT)).to_be_visible(timeout=30) + return term + + return _open + + def it_recalls_the_previous_statement_with_the_up_arrow(open_repl): + term = open_repl() + term.submit("SELECT 41 AS n;") + expect(term.get_by_text('[{"n":41}]')).to_be_visible(timeout=30) + + term.key_up() + # The recalled text is on the line before it is submitted: without + # this the arrow key and the enter could race the editor's redraw. + expect(term.get_by_text("SELECT 41 AS n;")).to_be_visible(timeout=10) + term.key_enter() + + expect(term.get_by_text('[{"n":41}]')).to_be_visible(timeout=30) + term.submit("quit") + expect(term).to_have_exited(timeout=30) + assert term.exit_code == 0 + + def it_keeps_history_across_sessions(open_repl): + # The point of a history *file*: a query worked out yesterday is still + # one up-arrow away today. + first = open_repl() + first.submit("SELECT 43 AS n;") + expect(first.get_by_text('[{"n":43}]')).to_be_visible(timeout=30) + first.key_ctrl_d() + expect(first).to_have_exited(timeout=30) + + second = open_repl() + second.key_up() + + expect(second.get_by_text("SELECT 43 AS n;")).to_be_visible(timeout=30) + + def it_writes_history_where_xdg_says(open_repl, home): + term = open_repl() + term.submit("SELECT 44 AS n;") + expect(term.get_by_text('[{"n":44}]')).to_be_visible(timeout=30) + term.key_ctrl_d() + expect(term).to_have_exited(timeout=30) + + history = home / "dirsql" / "history" + assert history.exists(), f"no history file at {history}" + assert "SELECT 44 AS n;" in history.read_text() + + def it_abandons_a_line_on_ctrl_c_without_ending_the_session(open_repl): + # #988's reason for routing the interrupt through the editor rather + # than a signal handler: the process must survive it. + term = open_repl() + term.write("SELECT 45 AS n") + expect(term.get_by_text("SELECT 45 AS n")).to_be_visible(timeout=10) + + term.key_ctrl_c() + + term.submit("SELECT 46 AS n;") + expect(term.get_by_text('[{"n":46}]')).to_be_visible(timeout=30) + assert term.is_alive, "Ctrl+C must not kill the session" + expect(term.get_by_text('[{"n":45}]')).not_to_be_visible(timeout=5) + + term.submit("quit") + expect(term).to_have_exited(timeout=30) + assert term.exit_code == 0 + + def it_shows_a_continuation_prompt_until_the_terminator(open_repl): + term = open_repl() + term.submit("SELECT") + + expect(term.get_by_text("...>")).to_be_visible(timeout=10) + + term.submit("47 AS n;") + expect(term.get_by_text('[{"n":47}]')).to_be_visible(timeout=30) + term.key_ctrl_d() + expect(term).to_have_exited(timeout=30) diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index 256b6403e..df8535566 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -46,6 +46,7 @@ cli = [ "dep:axum", "dep:clap", "dep:futures", + "dep:reedline", "dep:tokio-stream", "tokio/rt-multi-thread", "tokio/macros", @@ -64,6 +65,9 @@ globset = "0.4" # semantics), without adopting its walker: the scan keeps `walkdir`. ignore = "0.4" notify = "7" +# The REPL's interactive line editor (history, Ctrl+R, multi-line entry). +# CLI-only: a library consumer never pulls crossterm. +reedline = { version = "0.49", optional = true } rusqlite = { version = "0.34", features = ["bundled", "load_extension", "hooks", "vtab", "functions"] } serde.workspace = true serde_json.workspace = true diff --git a/packages/rust/changelog.d/2026-08-20-repl-line-editor.md b/packages/rust/changelog.d/2026-08-20-repl-line-editor.md new file mode 100644 index 000000000..e7d428997 --- /dev/null +++ b/packages/rust/changelog.d/2026-08-20-repl-line-editor.md @@ -0,0 +1,3 @@ +**Added** + +- **The REPL's interactive prompt is a full line editor.** On a terminal, bare `dirsql` now runs through [reedline](https://github.com/nushell/reedline): ↑/↓ history recall, Ctrl-R reverse search, emacs editing (word motions, kill/yank), and Ctrl-C to abandon a line and return to a fresh prompt without ending the session. History persists across sessions in one file for every directory — `$XDG_DATA_HOME/dirsql/history`, falling back to `~/.local/share/dirsql/history` (`%APPDATA%\dirsql\history` on Windows), the last 1000 statements — mirroring `sqlite3`'s single `~/.sqlite_history`; with no location resolvable it stays in memory for the session. Statements may now span lines: a typed statement ends at its semicolon, and **SQLite's own tokenizer** (`sqlite3_complete`) decides where that is, so a `;` inside a string literal, a comment, or a `BEGIN … END` body does not terminate it. A `...>` prompt marks a statement still being typed. The piped path is unchanged — no editor, no history, and still one statement per line with no terminator needed. (#988) diff --git a/packages/rust/migrations.d/2026-08-20-repl-line-editor.md b/packages/rust/migrations.d/2026-08-20-repl-line-editor.md new file mode 100644 index 000000000..dc912ac82 --- /dev/null +++ b/packages/rust/migrations.d/2026-08-20-repl-line-editor.md @@ -0,0 +1,67 @@ +### A typed REPL statement ends at its semicolon (#988) + +#### Summary + +The REPL read one line per statement. On a terminal it now reads until the +statement is **terminated**, exactly as `sqlite3` does, so a statement can span +lines. No API changed and no flag was added or removed; what a keystroke means +at the interactive prompt did. + +The piped path is untouched: `dirsql < script.sql` and +`echo "SELECT 1" | dirsql` still run one statement per line with no terminator. + +#### Required changes + +| Before | After | Fix | +| ------ | ----- | --- | +| Typing `SELECT 1` ⏎ at the prompt ran it | The prompt becomes `...>` and waits | Terminate it: `SELECT 1;`. | +| Ctrl-C at the prompt killed the process | Abandons the current line, session continues | Use Ctrl-D, `exit`, or `quit` to leave. | +| No history file was written | `$XDG_DATA_HOME/dirsql/history` (or `~/.local/share/dirsql/history`) is created and appended to | Nothing, unless you want it elsewhere — point `XDG_DATA_HOME` at another directory, or unset both `XDG_DATA_HOME` and `HOME` for an in-memory session. | + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- **Statements typed at a terminal now require a `;`.** An unterminated one is + never executed: reaching EOF mid-statement discards the fragment rather than + running half of it. +- `exit`, `quit`, and a blank line are exempt from the terminator rule — none + is SQL, and waiting for a semicolon on the one input meant to end the session + would trap the user. +- **Ctrl-C no longer terminates the process** at the prompt. It is handled by + the editor rather than a signal handler, so it clears the line and re-prompts. + A `SIGINT` delivered from outside (`kill -INT`) is unaffected. +- The REPL writes a history file where it previously wrote nothing. It holds + the last 1000 statements and is shared across directories, so statements from + one project are recallable in another. +- On a terminal the prompt is now drawn by the editor rather than written to + stdout directly. It is still `dirsql> `, with ` ...> ` for a continuation. + +#### Verification + +```bash +# A statement over several lines runs as one: +dirsql +# dirsql> SELECT basename, size +# ...> FROM './' +# ...> LIMIT 1; +# [{"basename":"a.md","size":6}] + +# A semicolon inside a literal is data, not a terminator: +# dirsql> SELECT ';' AS s; +# [{"s":";"}] + +# Ctrl-C clears the line; the session stays up. Ctrl-D leaves: +echo $? +# 0 + +# The piped path is unchanged -- no terminator needed: +printf 'SELECT 1 AS n\nSELECT 2 AS n\n' | dirsql +# [{"n":1}] +# [{"n":2}] + +# History lands where XDG says: +ls "${XDG_DATA_HOME:-$HOME/.local/share}/dirsql/history" +``` diff --git a/packages/rust/src/cli/repl.rs b/packages/rust/src/cli/repl.rs index 1998a1799..b0e202e2a 100644 --- a/packages/rust/src/cli/repl.rs +++ b/packages/rust/src/cli/repl.rs @@ -7,28 +7,50 @@ //! drift. //! //! Two halves, selected by one boolean the caller derives from -//! `stdin().is_terminal()`: +//! `stdin().is_terminal()`, differing only in where their statements come +//! from ([`Lines`]): //! -//! - **interactive** — a banner, a prompt before each read, and a trailing -//! newline on exit so the shell prompt starts on its own line. -//! - **piped** — none of that furniture, so `dirsql < script.sql > out.json` -//! yields results alone. +//! - **interactive** — a banner, then [`reedline`]: history recall and +//! `Ctrl+R` search, in-line editing, `Ctrl+C` to abandon a line without +//! killing the process, and multi-line entry. A statement ends at its +//! semicolon, exactly as in `sqlite3`, and SQLite's own tokenizer decides +//! where that semicolon is (see [`sql_is_complete`]). +//! - **piped** — a plain line reader, so `dirsql < script.sql > out.json` +//! yields results alone. **One statement per line, no terminator needed**: +//! a redirected script is not being typed, so there is no continuation +//! prompt for a multi-line rule to hang off. //! //! Both halves share the loop, and the differences from one-shot //! [`run_query`](super::run) are deliberate: a failing statement is reported //! and the session **continues**, and a clean EOF exits `0` regardless of //! per-statement failures (matching interactive `sqlite3`). +use std::borrow::Cow; +use std::ffi::CString; use std::io::{BufRead, Write}; +use std::path::PathBuf; +use reedline::{ + FileBackedHistory, Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, + Reedline, Signal, ValidationResult, Validator, +}; use serde_json::Value; use super::AppState; use super::execute::{QueryFailure, execute_query}; use super::run::query_body; -/// Printed before each read on the interactive path. -const PROMPT: &str = "dirsql> "; +/// The interactive prompt, split as reedline renders it: the left half, then +/// the indicator that follows it. +const PROMPT_LEFT: &str = "dirsql"; +const PROMPT_INDICATOR: &str = "> "; + +/// Shown instead of the indicator while a statement is still being typed. +const PROMPT_CONTINUATION: &str = " ...> "; + +/// How many statements the history file keeps. reedline's own default; a +/// REPL history is recall, not an archive. +const HISTORY_CAPACITY: usize = 1000; /// Where the REPL sends a statement. The real implementation is the shared /// [`execute_query`] pipeline; the loop is generic over this so its control @@ -49,6 +71,25 @@ impl Statements for StateStatements<'_> { } } +/// One read from the user, and what ended it. +enum Entry { + /// A statement to run. On the interactive path this is a whole statement, + /// however many lines it took to type. + Statement(String), + /// `Ctrl+C`: the line was abandoned. The session continues from a fresh + /// prompt rather than dying, which is the whole point of routing the + /// interrupt through the editor instead of a signal handler. + Cancelled, + /// `Ctrl+D`, or the end of a redirected script. + Ended, +} + +/// Where the REPL's statements come from. The two implementations are the +/// TTY/pipe split: an editor on one side, a plain line reader on the other. +trait Lines { + async fn next(&mut self) -> std::io::Result; +} + /// Run the REPL over `input`, rendering results to `out` and diagnostics to /// `err`, and return the process exit code. /// @@ -71,44 +112,49 @@ where let _ = writeln!(err, "dirsql: {reason}"); return 1; } - repl_loop(&StateStatements(state), input, out, err, interactive).await + let statements = StateStatements(state); + + if interactive { + repl_loop(&statements, EditorLines::new(), out, err, true).await + } else { + repl_loop(&statements, PipedLines(Some(input)), out, err, false).await + } } -/// The loop itself, generic over its statement sink so unit tests can drive -/// it with a double. -async fn repl_loop( +/// The loop itself, generic over both its statement sink and its input, so +/// unit tests can drive it with doubles. +async fn repl_loop( statements: &S, - mut input: R, + mut lines: L, out: &mut W, err: &mut E, interactive: bool, ) -> u8 where S: Statements, - R: BufRead + Send + 'static, + L: Lines, W: Write, E: Write, { - if interactive && write!(out, "{}", banner()).is_err() { + if interactive && (write!(out, "{}", banner()).is_err() || out.flush().is_err()) { return 1; } loop { - if interactive && (write!(out, "{PROMPT}").is_err() || out.flush().is_err()) { - return 1; - } - let line = match next_line(input).await { - Ok((reader, Some(line))) => { - input = reader; - line - } - Ok((_, None)) => break, + let entry = match lines.next().await { + Ok(entry) => entry, Err(read_err) => { let _ = writeln!(err, "dirsql: failed to read input: {read_err}"); return 1; } }; - let sql = line.trim(); + let statement = match entry { + Entry::Statement(statement) => statement, + Entry::Cancelled => continue, + Entry::Ended => break, + }; + + let sql = statement.trim(); if sql.is_empty() { continue; } @@ -136,26 +182,178 @@ where 0 } -/// Read one line off `reader`, handing the reader back. `None` is EOF. +/// The piped half: one statement per line, straight off the reader. /// -/// The read happens on a blocking thread: `run_cli` drives the whole CLI -/// inside `runtime.block_on`, so reading inline would park the thread the -/// runtime is driven on while the live watcher still needs it. -async fn next_line(mut reader: R) -> std::io::Result<(R, Option)> +/// The reader lives in an `Option` so it can be moved onto a blocking thread +/// and back for each read. `run_cli` drives the whole CLI inside +/// `runtime.block_on`, so reading inline would park the thread the runtime is +/// driven on while the live watcher still needs it. +struct PipedLines(Option); + +impl Lines for PipedLines where R: BufRead + Send + 'static, { - tokio::task::spawn_blocking(move || { - let mut line = String::new(); - // A count of 0 is EOF and only EOF: a real line always carries at - // least its terminator, so an empty `line` cannot mean anything else. - match reader.read_line(&mut line)? { - 0 => Ok((reader, None)), - _ => Ok((reader, Some(line))), + async fn next(&mut self) -> std::io::Result { + let Some(mut reader) = self.0.take() else { + return Ok(Entry::Ended); + }; + let (reader, entry) = + tokio::task::spawn_blocking(move || -> std::io::Result<(R, Entry)> { + let mut line = String::new(); + // A count of 0 is EOF and only EOF: a real line always carries at + // least its terminator, so an empty `line` cannot mean anything + // else. + match reader.read_line(&mut line)? { + 0 => Ok((reader, Entry::Ended)), + _ => Ok((reader, Entry::Statement(line))), + } + }) + .await + .map_err(std::io::Error::other)??; + self.0 = Some(reader); + Ok(entry) + } +} + +/// The interactive half: reedline over the real terminal. +/// +/// The editor moves onto a blocking thread and back for each read, for the +/// same reason [`PipedLines`] does. +struct EditorLines(Option); + +impl EditorLines { + fn new() -> Self { + let editor = Reedline::create().with_validator(Box::new(SqlValidator)); + // History is a convenience, not a requirement: a session with nowhere + // to write one still edits and recalls within itself. + let editor = match history_path(|key| std::env::var(key).ok()) + .and_then(|path| FileBackedHistory::with_file(HISTORY_CAPACITY, path).ok()) + { + Some(history) => editor.with_history(Box::new(history)), + None => editor, + }; + Self(Some(editor)) + } +} + +impl Lines for EditorLines { + async fn next(&mut self) -> std::io::Result { + let Some(mut editor) = self.0.take() else { + return Ok(Entry::Ended); + }; + let (editor, entry) = tokio::task::spawn_blocking(move || { + let signal = editor.read_line(&ReplPrompt); + (editor, signal) + }) + .await + .map_err(std::io::Error::other)?; + self.0 = Some(editor); + + Ok(match entry? { + Signal::Success(statement) => Entry::Statement(statement), + Signal::CtrlD => Entry::Ended, + // `Ctrl+C`, and anything a future reedline adds for an event + // nothing here binds: abandon the line and prompt afresh. That + // neither runs something the user did not ask for nor ends a + // session the user did not end. `Signal` is `#[non_exhaustive]`, + // so this arm is required rather than merely defensive. + _ => Entry::Cancelled, + }) + } +} + +/// The editor's view of when a statement is finished. +struct SqlValidator; + +impl Validator for SqlValidator { + fn validate(&self, line: &str) -> ValidationResult { + match is_submittable(line) { + true => ValidationResult::Complete, + false => ValidationResult::Incomplete, } - }) - .await - .map_err(std::io::Error::other)? + } +} + +/// Whether the editor should hand `buffer` over rather than ask for another +/// line. Blank input and the exit words are not SQL, so completeness cannot +/// apply to them -- without this, `exit` would wait forever for a semicolon +/// it is never going to get. +fn is_submittable(buffer: &str) -> bool { + let trimmed = buffer.trim(); + trimmed.is_empty() || is_exit_word(trimmed) || sql_is_complete(buffer) +} + +/// Whether `sql` is a complete statement, according to SQLite's own +/// tokenizer: it ends at a `;` that is not inside a string literal, a +/// comment, or a `BEGIN ... END` body. Hand-rolling this is the classic way +/// to get `SELECT ';'` wrong. +fn sql_is_complete(sql: &str) -> bool { + let Ok(text) = CString::new(sql) else { + // An interior NUL cannot be handed to a C string. Call it complete so + // the pipeline reports the problem, rather than the editor waiting + // for a terminator the user has no way to supply. + return true; + }; + #[expect( + unsafe_code, + reason = "sqlite3_complete only tokenizes the NUL-terminated string it \ + is given, which the CString above owns for the call" + )] + let complete = unsafe { rusqlite::ffi::sqlite3_complete(text.as_ptr()) }; + complete != 0 +} + +/// Where the editor keeps its history, resolved from `var` (the environment). +/// +/// One file for every directory, mirroring `sqlite3`'s single +/// `~/.sqlite_history`: a query worked out in one project is worth recalling +/// in the next. `None` means the session keeps its history in memory. +fn history_path(var: impl Fn(&str) -> Option) -> Option { + let non_empty = |key| var(key).filter(|value| !value.is_empty()); + + if let Some(data_home) = non_empty("XDG_DATA_HOME") { + return Some(PathBuf::from(data_home).join("dirsql").join("history")); + } + if let Some(home) = non_empty("HOME") { + return Some( + PathBuf::from(home) + .join(".local") + .join("share") + .join("dirsql") + .join("history"), + ); + } + non_empty("APPDATA").map(|appdata| PathBuf::from(appdata).join("dirsql").join("history")) +} + +/// How the interactive prompt is drawn. +struct ReplPrompt; + +impl Prompt for ReplPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + PROMPT_LEFT.into() + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + "".into() + } + + fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> { + PROMPT_INDICATOR.into() + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + PROMPT_CONTINUATION.into() + } + + fn render_prompt_history_search_indicator(&self, search: PromptHistorySearch) -> Cow<'_, str> { + let failing = match search.status { + PromptHistorySearchStatus::Passing => "", + PromptHistorySearchStatus::Failing => "failing ", + }; + format!("({failing}reverse-search: {}) ", search.term).into() + } } /// `exit` and `quit` end the session. Neither is a valid SQL statement, so @@ -166,13 +364,14 @@ fn is_exit_word(line: &str) -> bool { } /// The interactive greeting. Static text only: no scan and no live rows, so -/// the first prompt appears immediately even in a large tree (#986). +/// the first prompt appears immediately even in a large tree (#986). The +/// samples carry their terminators, because typed statements end at one. fn banner() -> String { format!( "dirsql {version} — this directory is a database.\n\ \n \ - SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5\n \ - SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%'\n\ + SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5;\n \ + SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%';\n\ \n\ `exit`, `quit`, or Ctrl-D to leave.\n\n", version = env!("CARGO_PKG_VERSION") @@ -183,6 +382,7 @@ fn banner() -> String { mod tests { use super::*; use std::cell::RefCell; + use std::collections::HashMap; use std::io::Cursor; /// A [`Statements`] double: records every statement it is handed and @@ -217,6 +417,37 @@ mod tests { } } + /// A [`Lines`] double replaying a scripted sequence of entries, so the + /// loop's handling of every one -- including `Ctrl+C`, which no plain + /// reader can produce -- is exercised without a terminal. + struct FakeLines(std::vec::IntoIter>); + + impl FakeLines { + fn of(entries: Vec) -> Self { + Self(entries.into_iter().map(Ok).collect::>().into_iter()) + } + + /// One statement per element, then EOF. + fn statements(statements: &[&str]) -> Self { + let mut entries: Vec = statements + .iter() + .map(|s| Entry::Statement((*s).to_string())) + .collect(); + entries.push(Entry::Ended); + Self::of(entries) + } + + fn failing() -> Self { + Self(vec![Err(std::io::Error::other("input went away"))].into_iter()) + } + } + + impl Lines for FakeLines { + async fn next(&mut self) -> std::io::Result { + self.0.next().unwrap_or(Ok(Entry::Ended)) + } + } + /// A sink whose every write fails, standing in for a closed pipe. struct BrokenPipe; @@ -230,7 +461,7 @@ mod tests { } } - /// A sink that accepts writes but cannot flush -- a prompt sitting in a + /// A sink that accepts writes but cannot flush -- a greeting sitting in a /// buffer whose drain has gone away. struct FlushFails; @@ -270,76 +501,113 @@ mod tests { String::from_utf8(sink.to_vec()).expect("output must be UTF-8") } - async fn drive(script: &str, interactive: bool) -> (FakeStatements, String, String, u8) { + /// An environment lookup over a fixed map, so history resolution is + /// tested without reading the real one. + fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |key| map.get(key).cloned() + } + + async fn drive(entries: Vec) -> (FakeStatements, String, String, u8) { let fake = FakeStatements::default(); let (mut out, mut err) = (Vec::new(), Vec::new()); - let code = repl_loop(&fake, input(script), &mut out, &mut err, interactive).await; + let code = repl_loop(&fake, FakeLines::of(entries), &mut out, &mut err, false).await; (fake, text(&out), text(&err), code) } #[tokio::test] - async fn every_non_blank_line_is_executed_in_order() { - let (fake, _, _, code) = drive("SELECT 1\nSELECT 2\n", false).await; + async fn every_non_blank_statement_is_executed_in_order() { + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + let code = repl_loop( + &fake, + FakeLines::statements(&["SELECT 1", "SELECT 2"]), + &mut out, + &mut err, + false, + ) + .await; assert_eq!(fake.seen(), vec!["SELECT 1", "SELECT 2"]); + assert_eq!(text(&out), "[]\n[]\n", "each result on its own line"); assert_eq!(code, 0); } #[tokio::test] - async fn each_result_is_written_on_its_own_line() { + async fn statements_are_trimmed_before_execution() { let fake = FakeStatements::default(); let (mut out, mut err) = (Vec::new(), Vec::new()); repl_loop( &fake, - input("SELECT 1\nSELECT 2\n"), + FakeLines::statements(&[" SELECT 1 \n"]), &mut out, &mut err, false, ) .await; - assert_eq!(text(&out), "[]\n[]\n"); - } - - #[tokio::test] - async fn statements_are_trimmed_before_execution() { - let (fake, _, _, _) = drive(" SELECT 1 \n", false).await; - assert_eq!(fake.seen(), vec!["SELECT 1"]); } #[tokio::test] - async fn blank_and_whitespace_lines_are_skipped() { - let (fake, out, err, code) = drive("\n \n\t\n", false).await; + async fn blank_and_whitespace_entries_are_skipped() { + let (fake, out, err, code) = drive(vec![ + Entry::Statement("".into()), + Entry::Statement(" ".into()), + Entry::Statement("\t\n".into()), + Entry::Ended, + ]) + .await; assert!(fake.seen().is_empty(), "nothing was executed"); - assert_eq!(out, "", "a blank line renders nothing"); - assert_eq!(err, "", "a blank line is not an error"); + assert_eq!(out, "", "a blank entry renders nothing"); + assert_eq!(err, "", "a blank entry is not an error"); assert_eq!(code, 0); } #[tokio::test] - async fn a_final_line_without_a_newline_still_runs() { - // The last line of a heredoc or `printf` often arrives unterminated. - let (fake, _, _, _) = drive("SELECT 1", false).await; + async fn exit_ends_the_session_before_the_rest_of_the_input() { + let (fake, _, _, code) = drive(vec![ + Entry::Statement("SELECT 1".into()), + Entry::Statement("exit".into()), + Entry::Statement("SELECT 2".into()), + ]) + .await; assert_eq!(fake.seen(), vec!["SELECT 1"]); + assert_eq!(code, 0); } #[tokio::test] - async fn exit_ends_the_session_before_the_rest_of_the_input() { - let (fake, _, _, code) = drive("SELECT 1\nexit\nSELECT 2\n", false).await; + async fn quit_ends_the_session_before_the_rest_of_the_input() { + let (fake, _, _, code) = drive(vec![ + Entry::Statement("quit".into()), + Entry::Statement("SELECT 2".into()), + ]) + .await; - assert_eq!(fake.seen(), vec!["SELECT 1"]); + assert!(fake.seen().is_empty()); assert_eq!(code, 0); } #[tokio::test] - async fn quit_ends_the_session_before_the_rest_of_the_input() { - let (fake, _, _, code) = drive("quit\nSELECT 2\n", false).await; + async fn ctrl_c_abandons_the_line_and_keeps_the_session() { + // #988's reason for routing the interrupt through the editor rather + // than a signal handler: the process must survive it. + let (fake, _, err, code) = drive(vec![ + Entry::Cancelled, + Entry::Statement("SELECT 1".into()), + Entry::Ended, + ]) + .await; - assert!(fake.seen().is_empty()); + assert_eq!(fake.seen(), vec!["SELECT 1"], "the next line still runs"); + assert_eq!(err, "", "an abandoned line is not a failure"); assert_eq!(code, 0); } @@ -352,7 +620,7 @@ mod tests { let code = repl_loop( &fake, - input("SELECT nope\nSELECT 1\n"), + FakeLines::statements(&["SELECT nope", "SELECT 1"]), &mut out, &mut err, false, @@ -360,52 +628,77 @@ mod tests { .await; assert_eq!(text(&err), "dirsql: no such table: nowhere\n"); + assert_eq!(text(&out), "[]\n", "errors never reach the result sink"); assert_eq!(fake.seen(), vec!["SELECT nope", "SELECT 1"]); assert_eq!(code, 0, "a clean EOF exits 0 even after a failure"); } #[tokio::test] - async fn a_failure_renders_nothing_on_the_result_sink() { - let fake = FakeStatements::failing("boom"); - let (mut out, mut err) = (Vec::new(), Vec::new()); + async fn the_piped_path_writes_no_banner() { + let (_, out, _, _) = drive(vec![Entry::Statement("SELECT 1".into()), Entry::Ended]).await; - repl_loop(&fake, input("SELECT nope\n"), &mut out, &mut err, false).await; - - assert_eq!(text(&out), "", "errors never reach the result sink"); + assert_eq!(out, "[]\n", "results alone, no interactive furniture"); } #[tokio::test] - async fn the_piped_path_writes_no_prompt_and_no_banner() { - let (_, out, _, _) = drive("SELECT 1\n", false).await; + async fn the_interactive_path_closes_with_a_newline() { + // Ctrl-D leaves the cursor mid-line; without this the shell prompt + // returns on top of the dirsql prompt. + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); - assert_eq!(out, "[]\n", "results alone, no interactive furniture"); + repl_loop(&fake, FakeLines::of(vec![]), &mut out, &mut err, true).await; + + assert!(text(&out).ends_with("\n\n\n"), "{:?}", text(&out)); } #[tokio::test] async fn the_interactive_path_opens_with_the_banner() { - let (_, out, _, _) = drive("", true).await; + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); - assert!( - out.starts_with(&banner()), - "banner comes first, got {out:?}" - ); + repl_loop(&fake, FakeLines::of(vec![]), &mut out, &mut err, true).await; + + assert!(text(&out).starts_with(&banner()), "{:?}", text(&out)); } #[tokio::test] - async fn the_interactive_path_prompts_before_every_read() { - let (_, out, _, _) = drive("SELECT 1\nSELECT 2\n", true).await; + async fn a_closed_sink_abandons_the_banner_rather_than_looping() { + let fake = FakeStatements::default(); + let mut err = Vec::new(); + + let code = repl_loop( + &fake, + FakeLines::statements(&["SELECT 1"]), + &mut BrokenPipe, + &mut err, + true, + ) + .await; - // Two statements plus the read that hits EOF. - assert_eq!(out.matches(PROMPT).count(), 3, "got {out:?}"); + assert_eq!(code, 1); + assert!(fake.seen().is_empty()); } #[tokio::test] - async fn the_interactive_path_closes_with_a_newline() { - // Ctrl-D leaves the cursor mid-line; without this the shell prompt - // returns on top of the dirsql prompt. - let (_, out, _, _) = drive("", true).await; + async fn an_unflushable_banner_stops_the_session_too() { + // A greeting that reaches the buffer but never the terminal leaves the + // user staring at a blank screen, so it is as fatal as one that cannot + // be written at all -- both halves of the write have to be checked. + let fake = FakeStatements::default(); + let mut err = Vec::new(); + + let code = repl_loop( + &fake, + FakeLines::statements(&["SELECT 1"]), + &mut FlushFails, + &mut err, + true, + ) + .await; - assert!(out.ends_with("\n"), "got {out:?}"); + assert_eq!(code, 1); + assert!(fake.seen().is_empty(), "nothing runs behind a dead screen"); } #[tokio::test] @@ -413,7 +706,7 @@ mod tests { let fake = FakeStatements::default(); let (mut out, mut err) = (Vec::new(), Vec::new()); - let code = repl_loop(&fake, BrokenReader, &mut out, &mut err, false).await; + let code = repl_loop(&fake, FakeLines::failing(), &mut out, &mut err, false).await; assert_eq!(code, 1); assert_eq!( @@ -431,7 +724,7 @@ mod tests { let code = repl_loop( &fake, - input("SELECT 1\nSELECT 2\n"), + FakeLines::statements(&["SELECT 1", "SELECT 2"]), &mut BrokenPipe, &mut err, false, @@ -443,28 +736,70 @@ mod tests { } #[tokio::test] - async fn a_closed_sink_abandons_the_banner_rather_than_looping() { + async fn the_piped_reader_yields_one_statement_per_line() { + // The pipe half keeps slice 1's rule: a redirected script is not being + // typed, so there is no terminator to wait for. let fake = FakeStatements::default(); - let mut err = Vec::new(); + let (mut out, mut err) = (Vec::new(), Vec::new()); - let code = repl_loop(&fake, input("SELECT 1\n"), &mut BrokenPipe, &mut err, true).await; + repl_loop( + &fake, + PipedLines(Some(input("SELECT 1\nSELECT 2\n"))), + &mut out, + &mut err, + false, + ) + .await; - assert_eq!(code, 1); - assert!(fake.seen().is_empty()); + assert_eq!(fake.seen(), vec!["SELECT 1", "SELECT 2"]); } #[tokio::test] - async fn an_unflushable_prompt_stops_the_loop_too() { - // A prompt that reaches the buffer but never the terminal leaves the - // user typing blind, so it is as fatal as one that cannot be written - // at all -- both halves of the prompt write have to be checked. + async fn the_piped_reader_runs_a_final_line_without_a_newline() { + // The last line of a heredoc or `printf` often arrives unterminated. let fake = FakeStatements::default(); - let mut err = Vec::new(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + repl_loop( + &fake, + PipedLines(Some(input("SELECT 1"))), + &mut out, + &mut err, + false, + ) + .await; + + assert_eq!(fake.seen(), vec!["SELECT 1"]); + } + + #[tokio::test] + async fn the_piped_reader_reports_a_read_failure() { + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); - let code = repl_loop(&fake, input("SELECT 1\n"), &mut FlushFails, &mut err, true).await; + let code = repl_loop( + &fake, + PipedLines(Some(BrokenReader)), + &mut out, + &mut err, + false, + ) + .await; assert_eq!(code, 1); - assert!(fake.seen().is_empty(), "nothing runs behind a dead prompt"); + assert_eq!( + text(&err), + "dirsql: failed to read input: input went away\n" + ); + } + + #[tokio::test] + async fn an_exhausted_piped_reader_stays_ended() { + // The reader is moved out for each blocking read; if one is ever lost + // the source must report EOF rather than being polled forever. + let mut lines = PipedLines(None::>>); + + assert!(matches!(lines.next().await, Ok(Entry::Ended))); } #[tokio::test] @@ -518,6 +853,176 @@ mod tests { } } + #[test] + fn a_terminated_statement_is_complete() { + assert!(sql_is_complete("SELECT 1;")); + assert!(sql_is_complete("SELECT 1;\n")); + } + + #[test] + fn an_unterminated_statement_is_incomplete() { + // The multi-line case: the editor keeps asking until the semicolon. + assert!(!sql_is_complete("SELECT 1")); + assert!(!sql_is_complete("SELECT basename,")); + assert!(!sql_is_complete("SELECT 1\nFROM t")); + } + + #[test] + fn a_semicolon_inside_a_string_literal_does_not_complete() { + // The classic trap, and the reason this is SQLite's call rather than + // a search for a trailing `;`. + assert!(!sql_is_complete("SELECT ';'")); + assert!(!sql_is_complete("SELECT 'a;b")); + assert!(sql_is_complete("SELECT ';';")); + } + + #[test] + fn a_semicolon_inside_a_comment_does_not_complete() { + assert!(!sql_is_complete("SELECT 1 -- ;")); + assert!(!sql_is_complete("SELECT 1 /* ; */")); + assert!(sql_is_complete("SELECT 1 /* ; */;")); + } + + #[test] + fn an_interior_nul_is_called_complete_so_the_pipeline_reports_it() { + // A C string cannot carry it, and holding the line hostage for a + // terminator the user cannot type would hang the session. + assert!(sql_is_complete("SELECT\u{0}1;")); + } + + #[test] + fn a_complete_statement_is_submittable() { + assert!(is_submittable("SELECT 1;")); + assert!(!is_submittable("SELECT 1")); + } + + #[test] + fn exit_words_are_submittable_without_a_terminator() { + // Neither is SQL, so waiting for a semicolon would hang the session on + // the one input meant to end it. + for word in ["exit", "quit", " EXIT "] { + assert!(is_submittable(word), "{word} must submit as typed"); + } + } + + #[test] + fn blank_input_is_submittable() { + // Pressing enter at a fresh prompt returns a fresh prompt; the loop + // skips it. Holding it as incomplete would trap the user. + assert!(is_submittable("")); + assert!(is_submittable(" \n\t")); + } + + #[test] + fn the_validator_reports_the_submittable_decision() { + assert!(matches!( + SqlValidator.validate("SELECT 1;"), + ValidationResult::Complete + )); + assert!(matches!( + SqlValidator.validate("SELECT 1"), + ValidationResult::Incomplete + )); + } + + #[test] + fn history_prefers_the_xdg_data_home() { + let path = history_path(env_of(&[ + ("XDG_DATA_HOME", "/data"), + ("HOME", "/home/someone"), + ])); + + assert_eq!(path, Some(PathBuf::from("/data/dirsql/history"))); + } + + #[test] + fn history_falls_back_to_the_xdg_default_under_home() { + let path = history_path(env_of(&[("HOME", "/home/someone")])); + + assert_eq!( + path, + Some(PathBuf::from("/home/someone/.local/share/dirsql/history")) + ); + } + + #[test] + fn history_falls_back_to_appdata() { + let path = history_path(env_of(&[("APPDATA", "C:\\Users\\someone\\AppData")])); + + assert_eq!( + path, + Some(PathBuf::from("C:\\Users\\someone\\AppData/dirsql/history")) + ); + } + + #[test] + fn an_empty_variable_is_not_a_location() { + // An exported-but-empty `XDG_DATA_HOME` would otherwise resolve to + // `dirsql/history` relative to the invocation directory, writing a + // history file into whatever tree the user happened to be querying. + let path = history_path(env_of(&[("XDG_DATA_HOME", ""), ("HOME", "/home/someone")])); + + assert_eq!( + path, + Some(PathBuf::from("/home/someone/.local/share/dirsql/history")) + ); + } + + #[test] + fn no_location_means_an_in_memory_history() { + assert_eq!(history_path(env_of(&[])), None); + } + + #[test] + fn the_prompt_renders_as_the_documented_string() { + // `docs/reference/cli.md` shows `dirsql> `, which reedline draws as + // the left half plus the indicator. + assert_eq!( + format!( + "{}{}", + ReplPrompt.render_prompt_left(), + ReplPrompt.render_prompt_indicator(PromptEditMode::Default) + ), + "dirsql> " + ); + } + + #[test] + fn the_continuation_prompt_differs_from_the_fresh_one() { + // A user mid-statement must be able to see that enter will not run it. + assert_ne!( + ReplPrompt.render_prompt_multiline_indicator(), + ReplPrompt.render_prompt_indicator(PromptEditMode::Default) + ); + assert_eq!(ReplPrompt.render_prompt_multiline_indicator(), " ...> "); + } + + #[test] + fn the_right_prompt_is_empty() { + assert_eq!(ReplPrompt.render_prompt_right(), ""); + } + + #[test] + fn the_history_search_indicator_names_the_term() { + let indicator = ReplPrompt.render_prompt_history_search_indicator(PromptHistorySearch { + status: PromptHistorySearchStatus::Passing, + term: "basename".to_string(), + }); + + assert_eq!(indicator, "(reverse-search: basename) "); + } + + #[test] + fn the_history_search_indicator_marks_a_miss() { + // Without this the user cannot tell a stale match from no match. + let indicator = ReplPrompt.render_prompt_history_search_indicator(PromptHistorySearch { + status: PromptHistorySearchStatus::Failing, + term: "nope".to_string(), + }); + + assert_eq!(indicator, "(failing reverse-search: nope) "); + } + #[test] fn the_banner_names_the_version_and_how_to_leave() { let banner = banner(); @@ -530,7 +1035,8 @@ mod tests { #[test] fn the_banner_shows_runnable_sample_queries() { // The greeting is the whole onboarding surface: a user who has never - // seen a path-table needs one here. + // seen a path-table needs one here. They carry terminators, because + // that is what the interactive path now requires. let banner = banner(); assert!( @@ -538,6 +1044,9 @@ mod tests { "{banner}" ); assert!(banner.contains("FROM './**/*.md'"), "{banner}"); + for line in banner.lines().filter(|l| l.trim().starts_with("SELECT")) { + assert!(line.trim_end().ends_with(';'), "{line:?} must terminate"); + } } #[test] diff --git a/packages/rust/tests/repl_tty_e2e.rs b/packages/rust/tests/repl_tty_e2e.rs index 45df1c138..91bf43916 100644 --- a/packages/rust/tests/repl_tty_e2e.rs +++ b/packages/rust/tests/repl_tty_e2e.rs @@ -1,82 +1,262 @@ //! End-to-end tests for the interactive (TTY) half of bare `dirsql` (#988). //! //! The interactive path only exists when stdin is a terminal, so these drive -//! the real binary under a **real PTY** allocated by util-linux `script`, -//! feeding keystrokes and reading back what the terminal received. Nothing is -//! mocked: real pty, real process, real filesystem, real SQLite. +//! the real binary under a **real PTY** (util-linux `script`) and type real +//! keystrokes at it, including arrow keys and `Ctrl+C`. Nothing is mocked: +//! real pty, real editor, real process, real filesystem, real SQLite. //! -//! The contract under test is multi-line entry: a statement ends at its -//! semicolon, as decided by SQLite's own tokenizer, so a statement may span -//! as many lines as it needs and a semicolon inside a string literal does not -//! end it. The **piped** path is deliberately untouched by this and stays one -//! statement per line -- `repl_pipe_e2e.rs` pins that. +//! A pty alone is not enough. The line editor asks the terminal where the +//! cursor is (a `ESC [ 6 n` Device Status Report) and stalls when nothing +//! answers -- there is a pty here but no terminal emulator behind it. So the +//! harness answers that one query itself, which is the whole of the emulation +//! these tests need. //! -//! Assertions are on the terminal's final contents rather than on timing, so -//! feeding stdin from a pipe is safe: the pty buffers the keystrokes and the -//! editor consumes them in order. Behaviors that genuinely depend on -//! keystroke *timing* (arrow-key recall, Ctrl+C mid-line) are covered by the -//! curtaincall suite in `packages/python/tests/e2e/`, which auto-waits. +//! What is under test is the part of #988's contract that a byte stream can +//! answer: a statement ends at its semicolon (SQLite's tokenizer decides +//! where), a continuation prompt marks an unfinished one, and the session +//! survives a failure. The **piped** path is deliberately untouched and stays +//! one statement per line -- `repl_pipe_e2e.rs` pins that. +//! +//! What is *not* here, and why: history recall, `Ctrl+C`, and anything else +//! that turns on a keystroke landing exactly when the editor is listening. +//! The editor repaints by moving the cursor, so this harness sees a byte +//! stream rather than a screen and can only guess when a redraw has finished +//! -- measured at roughly one miss in four. Those cases live in the +//! curtaincall suite under `packages/python/tests/e2e/`, which drives a real +//! VT100 emulator and waits on the screen itself. //! //! Linux-gated: `script -qec` is the util-linux spelling, and the Rust CI test //! job runs on Linux. #![cfg(all(feature = "cli", target_os = "linux"))] -use std::io::Write; -use std::process::{Command, Stdio}; +use std::io::{Read, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use assert_cmd::cargo::cargo_bin; use tempfile::TempDir; -/// What the terminal received, with ANSI escape sequences removed so -/// assertions read against the visible text the editor drew. +/// The cursor-position query the editor sends, and the reply the harness +/// gives: row 1, column 1 is a perfectly good answer for a fresh screen. +const CURSOR_QUERY: &[u8] = b"\x1b[6n"; +const CURSOR_REPLY: &[u8] = b"\x1b[1;1R"; + +/// Long enough for a debug-build startup and a directory scan, short enough +/// that a wedged editor fails the test instead of hanging the CI job. +const PATIENCE: Duration = Duration::from_secs(30); + +/// What the terminal received, with escape sequences removed so assertions +/// read against the visible text. struct Session { screen: String, code: Option, } -/// Run the real binary under a PTY over a fresh directory holding `files`, -/// typing `keys`. -fn repl(keys: &str, files: &[(&str, &str)]) -> Session { - let root = TempDir::new().unwrap(); - for (name, body) in files { - std::fs::write(root.path().join(name), body).unwrap(); +impl Session { + fn shows(&self, needle: &str) -> bool { + self.screen.contains(needle) + } +} + +/// A REPL session under a pty, with its own home so the suite never writes to +/// the developer's real history file. +struct Terminal { + home: TempDir, + root: TempDir, +} + +impl Terminal { + fn new() -> Self { + Self { + home: TempDir::new().unwrap(), + root: TempDir::new().unwrap(), + } + } + + /// Type `keys` at a fresh session and return what the terminal showed. + /// + /// Keys are sent only once the banner has been drawn, so the editor is + /// listening before the first keystroke arrives. + fn type_in(&self, keys: &str) -> Session { + let mut child = Command::new("script") + .arg("-qec") + .arg(cargo_bin("dirsql").to_str().expect("a UTF-8 binary path")) + .arg("/dev/null") + .current_dir(self.root.path()) + // Keep history inside the test's own tree: a suite that wrote to + // the developer's real history file would be unusable. + .env("XDG_DATA_HOME", self.home.path()) + .env("TERM", "xterm") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawning `script` failed -- util-linux must provide it"); + + let keyboard = Arc::new(Mutex::new(child.stdin.take().expect("stdin was piped"))); + let screen = Arc::new(Mutex::new(Vec::::new())); + let finished = Arc::new(AtomicBool::new(false)); + let reader = read_and_answer( + child.stdout.take().expect("stdout was piped"), + Arc::clone(&keyboard), + Arc::clone(&screen), + Arc::clone(&finished), + ); + + settle(&screen, &finished, |shown| { + String::from_utf8_lossy(shown).contains("Ctrl-D") + }); + // One line at a time, waiting for the editor to finish reacting to + // each. Sent in one burst, a keystroke aimed at the *next* prompt can + // arrive while the previous statement is still running and be lost -- + // which is exactly what an arrow key looks like when it goes missing. + for line in keystrokes(keys) { + let before = screen.lock().unwrap().len(); + if keyboard.lock().unwrap().write_all(line.as_bytes()).is_err() { + // The session ended on an earlier key (Ctrl-D, `quit`); the + // rest of the script has nothing to type at. + break; + } + settle(&screen, &finished, |shown| shown.len() > before); + quiesce(&screen, &finished); + } + + let code = wait_or_kill(&mut child); + finished.store(true, Ordering::SeqCst); + reader.join().expect("the reader thread panicked"); + + let mut raw = screen.lock().unwrap().clone(); + let mut errors = Vec::new(); + if let Some(mut stderr) = child.stderr.take() { + let _ = stderr.read_to_end(&mut errors); + } + raw.extend_from_slice(&errors); + + Session { + screen: strip_escapes(&String::from_utf8_lossy(&raw)).replace('\r', ""), + code, + } + } +} + +/// Drain the child's output into `screen`, answering each cursor-position +/// query as it arrives. Without the answer the editor never paints. +fn read_and_answer( + mut stdout: std::process::ChildStdout, + keyboard: Arc>, + screen: Arc>>, + finished: Arc, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match stdout.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(read) => { + let seen = &chunk[..read]; + screen.lock().unwrap().extend_from_slice(seen); + for _ in 0..count_queries(seen) { + if finished.load(Ordering::SeqCst) { + break; + } + let mut keys = keyboard.lock().unwrap(); + if keys.write_all(CURSOR_REPLY).is_err() { + break; + } + let _ = keys.flush(); + } + } + } + } + }) +} + +fn count_queries(bytes: &[u8]) -> usize { + bytes + .windows(CURSOR_QUERY.len()) + .filter(|window| *window == CURSOR_QUERY) + .count() +} + +/// Split typed input into the groups that each end one editor interaction: +/// a line of text, or a control key that returns from `read_line` on its own. +/// Anything sent after such a key in the same burst is still sitting in the +/// pty when the editor restarts, and is lost. +fn keystrokes(keys: &str) -> Vec { + let mut groups = Vec::new(); + let mut group = String::new(); + for ch in keys.chars() { + group.push(ch); + if ch == '\n' || ch == '\u{3}' || ch == '\u{4}' { + groups.push(std::mem::take(&mut group)); + } + } + if !group.is_empty() { + groups.push(group); + } + groups +} + +/// Block until `ready` holds of what the terminal has shown so far, or the +/// session ends, or patience runs out. +fn settle(screen: &Arc>>, finished: &Arc, ready: F) +where + F: Fn(&[u8]) -> bool, +{ + let deadline = Instant::now() + PATIENCE; + while Instant::now() < deadline && !finished.load(Ordering::SeqCst) { + if ready(&screen.lock().unwrap()) { + return; + } + std::thread::sleep(Duration::from_millis(10)); } - let binary = cargo_bin("dirsql"); - - let mut child = Command::new("script") - .arg("-qec") - .arg(binary.to_str().expect("the binary path must be UTF-8")) - .arg("/dev/null") - .current_dir(root.path()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawning `script` failed -- util-linux must provide it"); - child - .stdin - .take() - .expect("stdin was piped") - .write_all(keys.as_bytes()) - .expect("typing at the pty failed"); - let out = child - .wait_with_output() - .expect("waiting on `script` failed"); - - // `script` merges the child's stdout and stderr onto the pty, which is - // what a user sees; the split is asserted by the piped suite instead. - let mut screen = String::from_utf8_lossy(&out.stdout).into_owned(); - screen.push_str(&String::from_utf8_lossy(&out.stderr)); - Session { - screen: strip_ansi(&screen).replace('\r', ""), - code: out.status.code(), +} + +/// Block until the terminal stops changing, so the editor has finished +/// reacting to the last keystroke. +/// +/// This is a settling heuristic, not a screen read: the editor repaints by +/// moving the cursor, so the byte stream these tests see is not the screen +/// and cannot be turned back into one by dropping escapes. That is why the +/// assertions below ask only whether some text was ever drawn, and why the +/// tests that depend on precise keystroke sequencing (history recall, Ctrl+C) +/// live in the curtaincall suite, which drives a real VT100 emulator. +fn quiesce(screen: &Arc>>, finished: &Arc) { + let deadline = Instant::now() + PATIENCE; + let mut last = usize::MAX; + while Instant::now() < deadline && !finished.load(Ordering::SeqCst) { + let now = screen.lock().unwrap().len(); + if now == last { + return; + } + last = now; + std::thread::sleep(Duration::from_millis(60)); + } +} + +/// Wait for the session to end, killing it if it wedges so the test reports a +/// failure rather than hanging the job. +fn wait_or_kill(child: &mut Child) -> Option { + let deadline = Instant::now() + PATIENCE; + loop { + match child.try_wait().expect("waiting on `script` failed") { + Some(status) => return status.code(), + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + None => std::thread::sleep(Duration::from_millis(20)), + } } } -/// Drop CSI / OSC escape sequences the line editor emits to move the cursor -/// and colour the prompt. -fn strip_ansi(raw: &str) -> String { +/// Drop the escape sequences the editor emits to move the cursor, colour the +/// prompt, and save/restore position. +fn strip_escapes(raw: &str) -> String { let mut out = String::with_capacity(raw.len()); let mut chars = raw.chars().peekable(); while let Some(ch) = chars.next() { @@ -105,6 +285,8 @@ fn strip_ansi(raw: &str) -> String { } } } + // Two-byte escapes (save/restore cursor, charset selection): the + // one byte already consumed is the whole of it. _ => {} } } @@ -115,32 +297,41 @@ fn strip_ansi(raw: &str) -> String { fn a_statement_split_across_lines_runs_as_one() { // The headline of #988: a statement ends at its semicolon, not at the // first newline, so it can be laid out over as many lines as it needs. - let session = repl("SELECT\n1 AS n;\nquit\n", &[]); + let session = Terminal::new().type_in("SELECT\n1 AS n;\nquit\n"); assert!( - session.screen.contains(r#"[{"n":1}]"#), + session.shows(r#"[{"n":1}]"#), "the two lines ran as one statement, got:\n{}", session.screen ); assert!( - !session.screen.contains("incomplete input"), + !session.shows("incomplete input"), "the first line must not be executed on its own, got:\n{}", session.screen ); } #[test] -fn a_lone_semicolon_terminates_the_statement_above_it() { - // The terminator does not have to share a line with the statement. - let session = repl("SELECT 1 AS n\n;\nquit\n", &[]); +fn an_unfinished_statement_shows_a_continuation_prompt() { + // Without a distinct prompt the user cannot tell that enter will not run + // what they have typed. + let session = Terminal::new().type_in("SELECT\n1 AS n;\nquit\n"); assert!( - session.screen.contains(r#"[{"n":1}]"#), - "got:\n{}", + session.shows("...>"), + "the second line was asked for, got:\n{}", session.screen ); +} + +#[test] +fn a_lone_semicolon_terminates_the_statement_above_it() { + // The terminator does not have to share a line with the statement. + let session = Terminal::new().type_in("SELECT 1 AS n\n;\nquit\n"); + + assert!(session.shows(r#"[{"n":1}]"#), "got:\n{}", session.screen); assert!( - !session.screen.contains("error"), + !session.shows("error"), "nothing was executed prematurely, got:\n{}", session.screen ); @@ -150,55 +341,41 @@ fn a_lone_semicolon_terminates_the_statement_above_it() { fn a_semicolon_inside_a_string_literal_does_not_terminate() { // The classic trap, and the reason completeness is SQLite's tokenizer's // call rather than a search for a trailing `;`. - let session = repl("SELECT\n';' AS s;\nquit\n", &[]); + let session = Terminal::new().type_in("SELECT\n';' AS s;\nquit\n"); assert!( - session.screen.contains(r#"[{"s":";"}]"#), + session.shows(r#"[{"s":";"}]"#), "the literal's semicolon is data, not a terminator, got:\n{}", session.screen ); } -#[test] -fn an_unterminated_statement_is_never_executed() { - // Reaching EOF mid-statement discards the fragment rather than running - // half of it. - let session = repl("SELECT 1 AS n\n", &[]); - - assert!( - !session.screen.contains(r#"[{"n":1}]"#), - "a fragment must not run, got:\n{}", - session.screen - ); - assert_eq!(session.code, Some(0), "EOF is still a clean exit"); -} - #[test] fn exit_words_still_leave_without_a_terminator() { // `exit` is a REPL word, not SQL, so the completeness rule must let it // through rather than waiting for a semicolon that never comes. - let session = repl("exit\n", &[]); + let session = Terminal::new().type_in("exit\n"); assert_eq!(session.code, Some(0), "got:\n{}", session.screen); assert!( - !session.screen.contains("error"), + !session.shows("error"), "`exit` is not handed to SQLite, got:\n{}", session.screen ); } #[test] -fn a_completed_statement_still_reports_its_own_failure_and_continues() { +fn a_failed_statement_still_leaves_the_session_running() { // Slice 1's continue-on-error survives multi-line entry. - let session = repl("SELECT nope\nFROM missing;\nSELECT 1 AS n;\nquit\n", &[]); + let session = Terminal::new().type_in("SELECT nope\nFROM missing;\nSELECT 1 AS n;\nquit\n"); assert!( - session.screen.contains("missing"), + session.shows("missing"), "the failure names the table, got:\n{}", session.screen ); assert!( - session.screen.contains(r#"[{"n":1}]"#), + session.shows(r#"[{"n":1}]"#), "the next statement still runs, got:\n{}", session.screen ); From 3510493ccfe30def1973d723cef393d9ea75e0eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:39:46 +0000 Subject: [PATCH 3/9] e2e attestation for claude/epic-953-slice2-reedline --- .../e2e-attestations/claude-epic-953-slice2-reedline.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json diff --git a/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json b/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json new file mode 100644 index 000000000..7fdd18809 --- /dev/null +++ b/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/ -x -q", + "ran_at": 1787261986, + "exit_code": 0, + "commit": "cb75f4ef84a2c6cdd8632d6dbc5251b638f1b0f1", + "branch": "claude/epic-953-slice2-reedline" +} From 5952daec2ad1338b1dd38d243f12fa32310f6f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:41:34 +0000 Subject: [PATCH 4/9] e2e attestation for claude/epic-953-slice2-reedline --- .../e2e-attestations/claude-epic-953-slice2-reedline.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json diff --git a/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json b/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json new file mode 100644 index 000000000..2ea3b8448 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1787262094, + "exit_code": 0, + "commit": "3510493ccfe30def1973d723cef393d9ea75e0eb", + "branch": "claude/epic-953-slice2-reedline" +} From 75a8009fa793e77f0a3abf22b7311dddeced6d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:54:19 +0000 Subject: [PATCH 5/9] test: pin that Ctrl-D leaves the REPL The editor reports Ctrl-D distinctly from every other signal, and nothing in the Rust suite covered that arm: deleting it lets Ctrl-D fall into the catch-all that abandons a line, turning the standard way out of a shell into a keystroke that clears the line and does nothing else. Found by running the mutation gate against this slice's diff; verified by hand that the case fails with the arm removed and passes with it. Refs #988 skip-changelog: test-only; the behavior's fragment is in this branch already Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- packages/rust/tests/repl_tty_e2e.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/rust/tests/repl_tty_e2e.rs b/packages/rust/tests/repl_tty_e2e.rs index 91bf43916..a2abc9174 100644 --- a/packages/rust/tests/repl_tty_e2e.rs +++ b/packages/rust/tests/repl_tty_e2e.rs @@ -364,6 +364,26 @@ fn exit_words_still_leave_without_a_terminator() { ); } +#[test] +fn ctrl_d_leaves_the_session() { + // The editor reports `Ctrl+D` distinctly from every other signal, and + // confusing the two would turn the standard way out of a shell into a + // keystroke that clears the line and does nothing else. + let session = Terminal::new().type_in("SELECT 1 AS n;\n\u{4}"); + + assert!( + session.shows(r#"[{"n":1}]"#), + "the statement ran first, got:\n{}", + session.screen + ); + assert_eq!( + session.code, + Some(0), + "Ctrl-D ends the session cleanly, got:\n{}", + session.screen + ); +} + #[test] fn a_failed_statement_still_leaves_the_session_running() { // Slice 1's continue-on-error survives multi-line entry. From f51e578dbee1f6645d0dd3a3323acf60e0083313 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 22:05:44 +0000 Subject: [PATCH 6/9] refactor(cli): draw the prompt through reedline's own, overriding only `...>` The hand-rolled `Prompt` impl duplicated reedline's rendering, including a history-search line that was byte-for-byte what `DefaultPrompt` already produces. Wrapping it instead leaves one method ours -- the continuation marker, where `sqlite3`'s `...>` beats reedline's `::: ` for people who came from a SQL shell. The duplication had a concrete cost the mutation gate found: an empty right prompt written as a constant has no killable mutant, because `Cow::Borrowed("")` *is* what the function returned. Delegating makes the emptiness a property of the segment the prompt was built with rather than of the code, so a test that builds one with non-empty segments pins the forwarding and kills it. Verified by hand: hardcoding the return fails `the_prompt_forwards_the_segments_it_was_built_from`, and restoring the delegation passes it. Refs #988 skip-changelog: no observable change -- same prompt, same indicators Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- packages/rust/src/cli/repl.rs | 96 ++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/packages/rust/src/cli/repl.rs b/packages/rust/src/cli/repl.rs index b0e202e2a..41fe86b5c 100644 --- a/packages/rust/src/cli/repl.rs +++ b/packages/rust/src/cli/repl.rs @@ -31,8 +31,8 @@ use std::io::{BufRead, Write}; use std::path::PathBuf; use reedline::{ - FileBackedHistory, Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, - Reedline, Signal, ValidationResult, Validator, + DefaultPrompt, DefaultPromptSegment, FileBackedHistory, Prompt, PromptEditMode, + PromptHistorySearch, Reedline, Signal, ValidationResult, Validator, }; use serde_json::Value; @@ -43,7 +43,6 @@ use super::run::query_body; /// The interactive prompt, split as reedline renders it: the left half, then /// the indicator that follows it. const PROMPT_LEFT: &str = "dirsql"; -const PROMPT_INDICATOR: &str = "> "; /// Shown instead of the indicator while a statement is still being typed. const PROMPT_CONTINUATION: &str = " ...> "; @@ -243,7 +242,7 @@ impl Lines for EditorLines { return Ok(Entry::Ended); }; let (editor, entry) = tokio::task::spawn_blocking(move || { - let signal = editor.read_line(&ReplPrompt); + let signal = editor.read_line(&ReplPrompt::new()); (editor, signal) }) .await @@ -327,20 +326,35 @@ fn history_path(var: impl Fn(&str) -> Option) -> Option { non_empty("APPDATA").map(|appdata| PathBuf::from(appdata).join("dirsql").join("history")) } -/// How the interactive prompt is drawn. -struct ReplPrompt; +/// How the interactive prompt is drawn: `dirsql> `, with `sqlite3`'s +/// continuation marker. +/// +/// reedline's own prompt supplies the segments, the `> ` indicator and the +/// history-search line. Only the continuation marker is ours: a SQL shell's +/// users already know `...>` from `sqlite3`, and reedline's `::: ` would be +/// the one piece of the prompt they had to learn. +struct ReplPrompt(DefaultPrompt); + +impl ReplPrompt { + fn new() -> Self { + Self(DefaultPrompt::new( + DefaultPromptSegment::Basic(PROMPT_LEFT.to_string()), + DefaultPromptSegment::Empty, + )) + } +} impl Prompt for ReplPrompt { fn render_prompt_left(&self) -> Cow<'_, str> { - PROMPT_LEFT.into() + self.0.render_prompt_left() } fn render_prompt_right(&self) -> Cow<'_, str> { - "".into() + self.0.render_prompt_right() } - fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> { - PROMPT_INDICATOR.into() + fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow<'_, str> { + self.0.render_prompt_indicator(edit_mode) } fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { @@ -348,11 +362,7 @@ impl Prompt for ReplPrompt { } fn render_prompt_history_search_indicator(&self, search: PromptHistorySearch) -> Cow<'_, str> { - let failing = match search.status { - PromptHistorySearchStatus::Passing => "", - PromptHistorySearchStatus::Failing => "failing ", - }; - format!("({failing}reverse-search: {}) ", search.term).into() + self.0.render_prompt_history_search_indicator(search) } } @@ -975,37 +985,59 @@ mod tests { #[test] fn the_prompt_renders_as_the_documented_string() { - // `docs/reference/cli.md` shows `dirsql> `, which reedline draws as - // the left half plus the indicator. + // `docs/reference/cli.md` shows `dirsql> `, which the editor draws as + // the left segment followed by the indicator. + let prompt = ReplPrompt::new(); + assert_eq!( format!( "{}{}", - ReplPrompt.render_prompt_left(), - ReplPrompt.render_prompt_indicator(PromptEditMode::Default) + prompt.render_prompt_left(), + prompt.render_prompt_indicator(PromptEditMode::Default) ), "dirsql> " ); } + #[test] + fn the_prompt_forwards_the_segments_it_was_built_from() { + // The wrapper exists to change the continuation marker and nothing + // else. One that answered from its own constants would drift from + // what the editor actually draws the moment either side changed. + let prompt = ReplPrompt(DefaultPrompt::new( + DefaultPromptSegment::Basic("left".to_string()), + DefaultPromptSegment::Basic("right".to_string()), + )); + + assert_eq!(prompt.render_prompt_left(), "left"); + assert_eq!(prompt.render_prompt_right(), "right"); + } + + #[test] + fn nothing_is_drawn_at_the_right() { + // Nothing here is worth the width: the directory is already the + // user's shell prompt, and the session is bound to one. + assert_eq!(ReplPrompt::new().render_prompt_right(), ""); + } + #[test] fn the_continuation_prompt_differs_from_the_fresh_one() { // A user mid-statement must be able to see that enter will not run it. + let prompt = ReplPrompt::new(); + assert_ne!( - ReplPrompt.render_prompt_multiline_indicator(), - ReplPrompt.render_prompt_indicator(PromptEditMode::Default) + prompt.render_prompt_multiline_indicator(), + prompt.render_prompt_indicator(PromptEditMode::Default) ); - assert_eq!(ReplPrompt.render_prompt_multiline_indicator(), " ...> "); - } - - #[test] - fn the_right_prompt_is_empty() { - assert_eq!(ReplPrompt.render_prompt_right(), ""); + assert_eq!(prompt.render_prompt_multiline_indicator(), " ...> "); } #[test] fn the_history_search_indicator_names_the_term() { - let indicator = ReplPrompt.render_prompt_history_search_indicator(PromptHistorySearch { - status: PromptHistorySearchStatus::Passing, + let prompt = ReplPrompt::new(); + + let indicator = prompt.render_prompt_history_search_indicator(PromptHistorySearch { + status: reedline::PromptHistorySearchStatus::Passing, term: "basename".to_string(), }); @@ -1015,8 +1047,10 @@ mod tests { #[test] fn the_history_search_indicator_marks_a_miss() { // Without this the user cannot tell a stale match from no match. - let indicator = ReplPrompt.render_prompt_history_search_indicator(PromptHistorySearch { - status: PromptHistorySearchStatus::Failing, + let prompt = ReplPrompt::new(); + + let indicator = prompt.render_prompt_history_search_indicator(PromptHistorySearch { + status: reedline::PromptHistorySearchStatus::Failing, term: "nope".to_string(), }); From 0cf253b15a6d227db592373e5860801e637a1831 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 22:06:46 +0000 Subject: [PATCH 7/9] e2e attestation for claude/epic-953-slice2-reedline --- .../e2e-attestations/claude-epic-953-slice2-reedline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json b/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json index 7fdd18809..2146cf068 100644 --- a/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json +++ b/packages/python/e2e-attestations/claude-epic-953-slice2-reedline.json @@ -1,7 +1,7 @@ { "command": "uv run python -m pytest tests/e2e/ -x -q", - "ran_at": 1787261986, + "ran_at": 1787263606, "exit_code": 0, - "commit": "cb75f4ef84a2c6cdd8632d6dbc5251b638f1b0f1", + "commit": "f51e578dbee1f6645d0dd3a3323acf60e0083313", "branch": "claude/epic-953-slice2-reedline" } From 3fc371db6413b8a18638f5544eb38c5e64df32c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 22:08:14 +0000 Subject: [PATCH 8/9] e2e attestation for claude/epic-953-slice2-reedline --- .../ts/e2e-attestations/claude-epic-953-slice2-reedline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json b/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json index 2ea3b8448..1920ea9ec 100644 --- a/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json +++ b/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json @@ -1,7 +1,7 @@ { "command": "pnpm test:e2e", - "ran_at": 1787262094, + "ran_at": 1787263694, "exit_code": 0, - "commit": "3510493ccfe30def1973d723cef393d9ea75e0eb", + "commit": "0cf253b15a6d227db592373e5860801e637a1831", "branch": "claude/epic-953-slice2-reedline" } From b04f77dcff91fd4d5b765b7ca0fdd92c3b6c3968 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 22:18:25 +0000 Subject: [PATCH 9/9] test: wait for the prompt, not the banner, before typing at the pty The harness started typing once the banner appeared, but the banner is written before the editor starts reading -- so the first keystroke could land in the gap and be read by nothing. Waiting for the output to stop instead is the real signal: the last thing drawn is the prompt. `ctrl_d_leaves_the_session` had the same problem one keystroke later. It sent Ctrl-D at the prompt that follows an executed statement, which is the gap that made history recall flaky enough to move to curtaincall. Sending it at a fresh prompt tests the same thing -- with the `Signal::CtrlD` arm removed the session still fails to exit -- without racing anything. Caught by running the whole integration suite in parallel, where the contention made the gap wide enough to lose the key; three consecutive full runs are clean now. Refs #988 skip-changelog: test-only Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- packages/rust/tests/repl_tty_e2e.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/rust/tests/repl_tty_e2e.rs b/packages/rust/tests/repl_tty_e2e.rs index a2abc9174..ecc147e03 100644 --- a/packages/rust/tests/repl_tty_e2e.rs +++ b/packages/rust/tests/repl_tty_e2e.rs @@ -109,6 +109,10 @@ impl Terminal { settle(&screen, &finished, |shown| { String::from_utf8_lossy(shown).contains("Ctrl-D") }); + // The banner is written before the editor starts reading, so its + // arrival does not mean a keystroke would land anywhere. Waiting for + // the output to stop does: the last thing drawn is the prompt. + quiesce(&screen, &finished); // One line at a time, waiting for the editor to finish reacting to // each. Sent in one burst, a keystroke aimed at the *next* prompt can // arrive while the previous statement is still running and be lost -- @@ -369,13 +373,13 @@ fn ctrl_d_leaves_the_session() { // The editor reports `Ctrl+D` distinctly from every other signal, and // confusing the two would turn the standard way out of a shell into a // keystroke that clears the line and does nothing else. - let session = Terminal::new().type_in("SELECT 1 AS n;\n\u{4}"); + // + // Sent at a fresh prompt rather than after a statement: a key aimed at + // the prompt that follows an executed statement can land in the gap + // before the editor resumes reading, and the point here is Ctrl-D, not + // that race. + let session = Terminal::new().type_in("\u{4}"); - assert!( - session.shows(r#"[{"n":1}]"#), - "the statement ran first, got:\n{}", - session.screen - ); assert_eq!( session.code, Some(0),