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/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..2146cf068 --- /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": 1787263606, + "exit_code": 0, + "commit": "f51e578dbee1f6645d0dd3a3323acf60e0083313", + "branch": "claude/epic-953-slice2-reedline" +} 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..41fe86b5c 100644 --- a/packages/rust/src/cli/repl.rs +++ b/packages/rust/src/cli/repl.rs @@ -7,28 +7,49 @@ //! 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::{ + DefaultPrompt, DefaultPromptSegment, FileBackedHistory, Prompt, PromptEditMode, + PromptHistorySearch, 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"; + +/// 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 +70,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 +111,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 +181,189 @@ 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::new()); + (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: `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> { + self.0.render_prompt_left() + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + self.0.render_prompt_right() + } + + 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> { + PROMPT_CONTINUATION.into() + } + + fn render_prompt_history_search_indicator(&self, search: PromptHistorySearch) -> Cow<'_, str> { + self.0.render_prompt_history_search_indicator(search) + } } /// `exit` and `quit` end the session. Neither is a valid SQL statement, so @@ -166,13 +374,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 +392,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 +427,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 +471,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 +511,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 +630,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 +638,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(); - // Two statements plus the read that hits EOF. - assert_eq!(out.matches(PROMPT).count(), 3, "got {out:?}"); + let code = repl_loop( + &fake, + FakeLines::statements(&["SELECT 1"]), + &mut BrokenPipe, + &mut err, + true, + ) + .await; + + 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(); - assert!(out.ends_with("\n"), "got {out:?}"); + let code = repl_loop( + &fake, + FakeLines::statements(&["SELECT 1"]), + &mut FlushFails, + &mut err, + true, + ) + .await; + + assert_eq!(code, 1); + assert!(fake.seen().is_empty(), "nothing runs behind a dead screen"); } #[tokio::test] @@ -413,7 +716,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 +734,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 +746,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()); - let code = repl_loop(&fake, input("SELECT 1\n"), &mut FlushFails, &mut err, true).await; + 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, + 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 +863,200 @@ 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 the editor draws as + // the left segment followed by the indicator. + let prompt = ReplPrompt::new(); + + assert_eq!( + format!( + "{}{}", + 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!( + prompt.render_prompt_multiline_indicator(), + prompt.render_prompt_indicator(PromptEditMode::Default) + ); + assert_eq!(prompt.render_prompt_multiline_indicator(), " ...> "); + } + + #[test] + fn the_history_search_indicator_names_the_term() { + let prompt = ReplPrompt::new(); + + let indicator = prompt.render_prompt_history_search_indicator(PromptHistorySearch { + status: reedline::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 prompt = ReplPrompt::new(); + + let indicator = prompt.render_prompt_history_search_indicator(PromptHistorySearch { + status: reedline::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 +1069,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 +1078,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 new file mode 100644 index 000000000..ecc147e03 --- /dev/null +++ b/packages/rust/tests/repl_tty_e2e.rs @@ -0,0 +1,407 @@ +//! 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** (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. +//! +//! 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. +//! +//! 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::{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; + +/// 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, +} + +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") + }); + // 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 -- + // 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)); + } +} + +/// 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 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() { + 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; + } + } + } + // Two-byte escapes (save/restore cursor, charset selection): the + // one byte already consumed is the whole of it. + _ => {} + } + } + 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 = Terminal::new().type_in("SELECT\n1 AS n;\nquit\n"); + + assert!( + session.shows(r#"[{"n":1}]"#), + "the two lines ran as one statement, got:\n{}", + session.screen + ); + assert!( + !session.shows("incomplete input"), + "the first line must not be executed on its own, got:\n{}", + session.screen + ); +} + +#[test] +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.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.shows("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 = Terminal::new().type_in("SELECT\n';' AS s;\nquit\n"); + + assert!( + session.shows(r#"[{"s":";"}]"#), + "the literal's semicolon is data, not a terminator, got:\n{}", + session.screen + ); +} + +#[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 = Terminal::new().type_in("exit\n"); + + assert_eq!(session.code, Some(0), "got:\n{}", session.screen); + assert!( + !session.shows("error"), + "`exit` is not handed to SQLite, got:\n{}", + session.screen + ); +} + +#[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. + // + // 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_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. + let session = Terminal::new().type_in("SELECT nope\nFROM missing;\nSELECT 1 AS n;\nquit\n"); + + assert!( + session.shows("missing"), + "the failure names the table, got:\n{}", + session.screen + ); + assert!( + session.shows(r#"[{"n":1}]"#), + "the next statement still runs, got:\n{}", + session.screen + ); + assert_eq!(session.code, Some(0)); +} 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..1920ea9ec --- /dev/null +++ b/packages/ts/e2e-attestations/claude-epic-953-slice2-reedline.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1787263694, + "exit_code": 0, + "commit": "0cf253b15a6d227db592373e5860801e637a1831", + "branch": "claude/epic-953-slice2-reedline" +}