From 94342da8a34b6b30e06190a37d8a896d33477e6c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 11:26:06 +0100 Subject: [PATCH 001/147] Add auto table format with intelligent rendering Implements --format=auto option that renders JSONLines_Compact output as formatted tables with automatic content wrapping. Features: - New table_renderer module for parsing JSONLines_Compact format - Dynamic terminal-width-aware table rendering using comfy-table - Automatic cell content wrapping to fit terminal width - Support for multiple DATA messages (accumulates before rendering) - Graceful error handling with fallback to raw output - Works in both single-query and REPL modes - Compatible with existing flags (--verbose, --concise) - Can be saved as default with --update-defaults Dependencies added: - terminal_size 0.3 for terminal width detection - comfy-table 6.2 for table rendering - home version constraint to avoid edition2024 issues All tests passing (41/41). Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 1519 ++++++++++++++++++++++++++++------------- Cargo.toml | 3 + src/args.rs | 13 +- src/main.rs | 1 + src/query.rs | 40 +- src/table_renderer.rs | 179 +++++ tests/cli.rs | 9 + 7 files changed, 1294 insertions(+), 470 deletions(-) create mode 100644 src/table_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 3432a9e..2196048 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,26 +2,11 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -32,32 +17,11 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "backtrace" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - [[package]] name = "base64" -version = "0.21.5" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" @@ -67,9 +31,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" @@ -82,30 +46,31 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.14.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" -version = "1.5.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.0.83" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ - "libc", + "find-msvc-tools", + "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clipboard-win" @@ -118,11 +83,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "comfy-table" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e959d788268e3bf9d35ace83e81b124190378e4c91c9067524675e33394b8ba" +dependencies = [ + "crossterm", + "strum", + "strum_macros", + "unicode-width", +] + [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", @@ -130,9 +107,9 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" @@ -143,11 +120,36 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "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 = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -181,14 +183,25 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] name = "encoding_rs" -version = "0.8.33" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] @@ -201,18 +214,18 @@ checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.5" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -227,16 +240,18 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fb" version = "0.2.3" dependencies = [ + "comfy-table", "dirs", "gumdrop", + "home 0.4.2", "once_cell", "openssl", "pest", @@ -247,6 +262,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "terminal_size", "tokio", "tokio-util", "toml", @@ -260,10 +276,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" dependencies = [ "cfg-if", - "rustix", - "windows-sys", + "rustix 0.38.44", + "windows-sys 0.48.0", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -287,50 +309,51 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -345,9 +368,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.10" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -355,10 +378,16 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.28.0" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] [[package]] name = "gumdrop" @@ -382,9 +411,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -401,41 +430,50 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.2" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "hermit-abi" -version = "0.3.3" +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "home" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "013e4e6e9134211bb4d6bf53dd8cfb75d9e2715cc33614b9c0827718c6fbe0b8" +dependencies = [ + "scopeguard", + "winapi", +] [[package]] name = "home" -version = "0.5.5" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "http" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] [[package]] name = "http-body" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -443,9 +481,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -456,30 +494,48 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.2.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -498,39 +554,136 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.3" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", - "tower", "tower-service", "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] name = "idna" -version = "0.4.0" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "icu_normalizer", + "icu_properties", ] [[package]] name = "indexmap" -version = "2.1.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -538,64 +691,90 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.9.0" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.65" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ + "once_cell", "wasm-bindgen", ] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "libc" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] -name = "libc" -version = "0.2.171" +name = "libredox" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.4.10" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.20" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mime" @@ -604,32 +783,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.7.1" +name = "mio" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ - "adler", + "libc", + "log", + "wasi", + "windows-sys 0.48.0", ] [[package]] name = "mio" -version = "0.8.11" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "native-tls" -version = "0.2.11" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ - "lazy_static", "libc", "log", "openssl", @@ -661,38 +842,19 @@ dependencies = [ "libc", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "object" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" -version = "1.18.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -709,29 +871,29 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.2.3+3.2.1" +version = "300.5.5+3.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cff92b6f71555b61bb9315f7c64da3ca43d87531622120fea0195fc761b4843" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -748,9 +910,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -758,39 +920,38 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.4.1", + "redox_syscall", "smallvec", - "windows-targets", + "windows-link", ] [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", - "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -798,53 +959,32 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "pest_meta" -version = "2.8.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ - "once_cell", "pest", "sha2", ] -[[package]] -name = "pin-project" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -854,28 +994,43 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.27" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radix_trie" version = "0.2.1" @@ -888,38 +1043,29 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.4.1" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", ] [[package]] name = "redox_users" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", - "redox_syscall 0.2.16", - "thiserror 1.0.50", + "getrandom 0.2.17", + "libredox", + "thiserror", ] [[package]] name = "regex" -version = "1.10.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -929,9 +1075,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -940,98 +1086,147 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.12.0" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b48d98d932f4ee75e541614d32a7f44c889b72bd9c2e04d95edd135989df88" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", - "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", "tokio-native-tls", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", ] [[package]] -name = "rustc-demangle" -version = "0.1.23" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] [[package]] name = "rustix" -version = "0.38.21" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustix" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "base64", + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] -name = "rustyline" -version = "12.0.0" +name = "rustls" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "bitflags 2.4.1", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "regex", - "scopeguard", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "12.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home 0.5.12", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "regex", + "scopeguard", "unicode-segmentation", "unicode-width", "utf8parse", @@ -1040,17 +1235,17 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "schannel" -version = "0.1.22" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1061,11 +1256,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.9.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -1074,9 +1269,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -1084,40 +1279,52 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.190" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91d3c334ca1ee894a2c6f6ad698fe8c435b76d504b13d436f0685d648d6d96f7" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.190" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c5609f394e5c2bd7fc51efda478004ea80ef42fee983d5c67a65e34f32c0e3" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.108" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "ryu", + "memchr", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "0.6.4" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -1136,9 +1343,9 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.27" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cc7a1570e38322cfe4154732e5110f887ea57e22b76f4bfd32b5bdd3368666c" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", @@ -1149,9 +1356,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -1159,45 +1366,101 @@ dependencies = [ ] [[package]] -name = "signal-hook-registry" -version = "1.4.1" +name = "shlex" +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 = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", + "signal-hook-registry", ] [[package]] -name = "slab" -version = "0.4.9" +name = "signal-hook-mio" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ - "autocfg", + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.5" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "str-buf" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1211,9 +1474,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.100" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -1222,26 +1485,40 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "system-configuration" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", @@ -1249,100 +1526,83 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.8.1" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "cfg-if", "fastrand", - "redox_syscall 0.4.1", - "rustix", - "windows-sys", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] -name = "thiserror" -version = "1.0.50" +name = "terminal_size" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" dependencies = [ - "thiserror-impl 1.0.50", + "rustix 0.38.44", + "windows-sys 0.48.0", ] [[package]] name = "thiserror" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" -dependencies = [ - "thiserror-impl 2.0.12", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.50" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] -name = "tinyvec" -version = "1.6.0" +name = "tinystr" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" -version = "1.38.2" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68722da18b0fc4a05fdc1120b302b82051265792a1e1b399086e9b204b10ad3d" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", "libc", - "mio", - "num_cpus", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] @@ -1355,25 +1615,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" -version = "0.7.10" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] [[package]] name = "toml" -version = "0.8.8" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", @@ -1383,85 +1652,108 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.5" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.21.0" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" -version = "0.4.13" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "pin-project", "pin-project-lite", + "sync_wrapper", "tokio", "tower-layer", "tower-service", - "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", ] [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "try-lock" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -1469,38 +1761,23 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "unicode-bidi" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" - [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unsafe-libyaml" @@ -1508,15 +1785,22 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" -version = "2.4.1" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -1525,11 +1809,17 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "vcpkg" @@ -1554,52 +1844,51 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasm-bindgen" -version = "0.2.88" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.88" +name = "wasm-bindgen" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ - "bumpalo", - "log", + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn 2.0.100", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.38" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.88" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1607,28 +1896,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.88" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.100", - "wasm-bindgen-backend", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.88" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.65" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -1656,13 +1948,84 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -1671,13 +2034,46 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1686,57 +2082,244 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" -version = "0.5.19" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829846f3e3db426d4cee4510841b71a8e58aa2a76b1132579487ae430ccd9c7b" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] -name = "winreg" -version = "0.50.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "cfg-if", - "windows-sys", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml index ac2a217..8f466fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,6 @@ toml = "0.8" urlencoding = "2.1" pest = "2.7" pest_derive = "2.7" +terminal_size = "0.3" +comfy-table = "6.2" +home = "< 0.5.12" diff --git a/src/args.rs b/src/args.rs index 4e4d4c7..62412c3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -108,6 +108,12 @@ pub struct Args { pub query: Vec, } +impl Args { + pub fn should_render_table(&self) -> bool { + self.format.eq_ignore_ascii_case("auto") + } +} + pub fn normalize_extras(extras: Vec, encode: bool) -> Result, Box> { let mut x: BTreeMap = BTreeMap::new(); @@ -228,7 +234,12 @@ pub fn get_url(args: &Args) -> String { let is_localhost = args.host.starts_with("localhost"); let protocol = if is_localhost { "http" } else { "https" }; let output_format = if !args.format.is_empty() && !args.extra.iter().any(|e| e.starts_with("format=")) { - format!("&output_format={}", args.format) + let server_format = if args.format.eq_ignore_ascii_case("auto") { + "JSONLines_Compact" + } else { + &args.format + }; + format!("&output_format={}", server_format) } else { String::new() }; diff --git a/src/main.rs b/src/main.rs index 45df4b4..e62d5fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod auth; mod context; mod meta_commands; mod query; +mod table_renderer; mod utils; use args::get_args; diff --git a/src/query.rs b/src/query.rs index d13043b..bf34ed9 100644 --- a/src/query.rs +++ b/src/query.rs @@ -9,6 +9,7 @@ use tokio_util::sync::CancellationToken; use crate::args::normalize_extras; use crate::auth::authenticate_service_account; use crate::context::Context; +use crate::table_renderer; use crate::utils::spin; use crate::FIREBOLT_PROTOCOL_VERSION; use crate::USER_AGENT; @@ -196,7 +197,44 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let body = resp.text().await?; // on stdout, on purpose - print!("{}", body); + if context.args.should_render_table() { + match table_renderer::parse_jsonlines_compact(&body) { + Ok(parsed) => { + if let Some(errors) = parsed.errors { + // Display errors + for error in errors { + eprintln!("Error: {}", error.description); + } + } else if !parsed.columns.is_empty() { + // Render table with dynamic wrapping + let table_output = table_renderer::render_table(&parsed.columns, &parsed.rows); + println!("{}", table_output); + + // Show statistics (if not --concise) + if !context.args.concise && parsed.statistics.is_some() { + if let Some(stats) = parsed.statistics.as_ref() { + if let Some(obj) = stats.as_object() { + eprintln!(""); // Empty line before stats + for (key, value) in obj { + eprintln!("{}: {}", key, value); + } + } + } + } + } + } + Err(e) => { + // Fallback to raw output on parse error + if context.args.verbose { + eprintln!("Failed to parse table format: {}", e); + } + println!("{}", body); + } + } + } else { + // Original behavior for other formats + println!("{}", body); + } if !status.is_success() { query_failed = true; diff --git a/src/table_renderer.rs b/src/table_renderer.rs new file mode 100644 index 0000000..415d5be --- /dev/null +++ b/src/table_renderer.rs @@ -0,0 +1,179 @@ +use serde::Deserialize; +use serde_json::Value; +use comfy_table::{Table, Cell, Color, Attribute, ContentArrangement}; +use terminal_size::{Width, terminal_size}; + +#[derive(Debug, Deserialize)] +#[serde(tag = "message_type")] +pub enum JsonLineMessage { + #[serde(rename = "START")] + Start { + result_columns: Vec, + query_id: String, + request_id: String, + query_label: Option, + }, + #[serde(rename = "DATA")] + Data { + data: Vec>, + }, + #[serde(rename = "FINISH_SUCCESSFULLY")] + FinishSuccessfully { + statistics: Option, + }, + #[serde(rename = "FINISH_WITH_ERRORS")] + FinishWithErrors { + errors: Vec, + }, +} + +#[derive(Debug, Deserialize)] +pub struct ResultColumn { + pub name: String, + #[serde(rename = "type")] + pub column_type: String, +} + +#[derive(Debug, Deserialize)] +pub struct ErrorDetail { + pub description: String, +} + +pub struct ParsedResult { + pub columns: Vec, + pub rows: Vec>, + pub statistics: Option, + pub errors: Option>, +} + +pub fn parse_jsonlines_compact(text: &str) -> Result> { + let mut columns: Vec = Vec::new(); + let mut all_rows: Vec> = Vec::new(); + let mut statistics: Option = None; + let mut errors: Option> = None; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let message: JsonLineMessage = serde_json::from_str(trimmed)?; + + match message { + JsonLineMessage::Start { result_columns, .. } => { + columns = result_columns; + } + JsonLineMessage::Data { data } => { + all_rows.extend(data); + } + JsonLineMessage::FinishSuccessfully { statistics: stats } => { + statistics = stats; + } + JsonLineMessage::FinishWithErrors { errors: errs } => { + errors = Some(errs); + } + } + } + + Ok(ParsedResult { + columns, + rows: all_rows, + statistics, + errors, + }) +} + +pub fn render_table(columns: &[ResultColumn], rows: &[Vec]) -> String { + let mut table = Table::new(); + + // Enable dynamic content arrangement for automatic wrapping + table.set_content_arrangement(ContentArrangement::Dynamic); + + // Detect and set terminal width if available + if let Some((Width(w), _)) = terminal_size() { + table.set_width(w); + } + + // Add headers with styling + let header_cells: Vec = columns + .iter() + .map(|col| { + Cell::new(&col.name) + .fg(Color::Cyan) + .add_attribute(Attribute::Bold) + }) + .collect(); + + table.set_header(header_cells); + + // Add data rows + for row in rows { + let row_cells: Vec = row + .iter() + .map(|val| Cell::new(format_value(val))) + .collect(); + + table.add_row(row_cells); + } + + table.to_string() +} + +fn format_value(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_jsonlines() { + let input = r#"{"message_type":"START","query_id":"123","request_id":"456","query_label":null,"result_columns":[{"name":"col1","type":"integer"},{"name":"col2","type":"text"}]} +{"message_type":"DATA","data":[[1,"hello"],[2,"world"]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{"elapsed":0.123}}"#; + + let result = parse_jsonlines_compact(input).unwrap(); + assert_eq!(result.columns.len(), 2); + assert_eq!(result.rows.len(), 2); + assert!(result.errors.is_none()); + } + + #[test] + fn test_parse_multiple_data_messages() { + let input = r#"{"message_type":"START","query_id":"123","request_id":"456","query_label":null,"result_columns":[{"name":"id","type":"integer"}]} +{"message_type":"DATA","data":[[1],[2]]} +{"message_type":"DATA","data":[[3],[4]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{}}"#; + + let result = parse_jsonlines_compact(input).unwrap(); + assert_eq!(result.rows.len(), 4); + } + + #[test] + fn test_parse_with_errors() { + let input = r#"{"message_type":"START","query_id":"123","request_id":"456","query_label":null,"result_columns":[]} +{"message_type":"FINISH_WITH_ERRORS","errors":[{"description":"Syntax error"}]}"#; + + let result = parse_jsonlines_compact(input).unwrap(); + assert!(result.errors.is_some()); + assert_eq!(result.errors.unwrap()[0].description, "Syntax error"); + } + + #[test] + fn test_format_value_null() { + assert_eq!(format_value(&Value::Null), "NULL"); + } + + #[test] + fn test_format_value_string() { + assert_eq!(format_value(&Value::String("test".to_string())), "test"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 35166a4..df2ffba 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -288,3 +288,12 @@ fn test_exit_code_on_query_error_interactive() { "Exit code should be non-zero when any query in session fails" ); } + +#[test] +fn test_auto_format() { + let (success, stdout, _) = run_fb(&["--core", "--format=auto", "SELECT 1 as id, 'test' as name"]); + assert!(success); + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); + assert!(stdout.contains("test")); +} From 94bc85bfa93261b45723437b4c5d6def2a99a4b3 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 13:30:57 +0100 Subject: [PATCH 002/147] Fix expanded table alignment and improve content wrapping - Add display_width() function to correctly calculate line width ignoring ANSI escape codes - Enable ContentArrangement::Dynamic for proper content wrapping in expanded mode - Add special handling for single-column chunks with wide content using UpperBoundary constraint - Make should_use_expanded_mode() consistent with rendering by applying same truncation logic - Add max_value_length parameter to render functions for context-specific truncation (1000 chars for expanded, 10000 for horizontal) - Add comprehensive tests for expanded mode, truncation, and ANSI width calculation Fixes alignment issues where very long truncated values (like settings_names with 1000+ chars) caused table borders to extend beyond terminal width. All chunks now align properly at the right edge. Co-Authored-By: Claude Sonnet 4.5 --- src/args.rs | 10 +- src/query.rs | 25 +- src/table_renderer.rs | 513 ++++++++++++++++++++++++++++++++++++++++-- tests/cli.rs | 32 +++ 4 files changed, 556 insertions(+), 24 deletions(-) diff --git a/src/args.rs b/src/args.rs index 62412c3..a829400 100644 --- a/src/args.rs +++ b/src/args.rs @@ -38,7 +38,7 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub database: String, - #[options(help = "Output format (e.g., TabSeparatedWithNames, PSQL, JSONLines_Compact, Vertical, ...)")] + #[options(help = "Output format (auto, expanded, PSQL, TabSeparatedWithNames, JSONLines_Compact, ...)")] #[serde(default)] pub format: String, @@ -110,7 +110,11 @@ pub struct Args { impl Args { pub fn should_render_table(&self) -> bool { - self.format.eq_ignore_ascii_case("auto") + self.format.eq_ignore_ascii_case("auto") || self.format.eq_ignore_ascii_case("expanded") + } + + pub fn is_expanded_mode(&self) -> bool { + self.format.eq_ignore_ascii_case("expanded") } } @@ -234,7 +238,7 @@ pub fn get_url(args: &Args) -> String { let is_localhost = args.host.starts_with("localhost"); let protocol = if is_localhost { "http" } else { "https" }; let output_format = if !args.format.is_empty() && !args.extra.iter().any(|e| e.starts_with("format=")) { - let server_format = if args.format.eq_ignore_ascii_case("auto") { + let server_format = if args.format.eq_ignore_ascii_case("auto") || args.format.eq_ignore_ascii_case("expanded") { "JSONLines_Compact" } else { &args.format diff --git a/src/query.rs b/src/query.rs index bf34ed9..5582ce7 100644 --- a/src/query.rs +++ b/src/query.rs @@ -206,8 +206,29 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< eprintln!("Error: {}", error.description); } } else if !parsed.columns.is_empty() { - // Render table with dynamic wrapping - let table_output = table_renderer::render_table(&parsed.columns, &parsed.rows); + // Get terminal width for intelligent display decisions + let terminal_width = terminal_size::terminal_size() + .map(|(terminal_size::Width(w), _)| w) + .unwrap_or(80); + + let table_output = if context.args.is_expanded_mode() { + // Explicit expanded mode - stricter truncation (1000 chars) + table_renderer::render_table_expanded(&parsed.columns, &parsed.rows, terminal_width, 1000) + } else { + // Auto mode - intelligently choose display mode + // Use 10000 char limit for detection (same as horizontal rendering) + if table_renderer::should_use_expanded_mode(&parsed.columns, &parsed.rows, terminal_width, 10000) { + if context.args.verbose { + eprintln!("Note: Using expanded display mode (table too wide for horizontal display)"); + } + // Auto-expanded mode - stricter truncation (1000 chars) + table_renderer::render_table_expanded(&parsed.columns, &parsed.rows, terminal_width, 1000) + } else { + // Horizontal mode - generous truncation (10k chars) + table_renderer::render_table(&parsed.columns, &parsed.rows, 10000) + } + }; + println!("{}", table_output); // Show statistics (if not --concise) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 415d5be..d54cbb5 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -1,7 +1,7 @@ +use comfy_table::{Attribute, Cell, Color, ColumnConstraint, ContentArrangement, Table, Width as ComfyWidth}; use serde::Deserialize; use serde_json::Value; -use comfy_table::{Table, Cell, Color, Attribute, ContentArrangement}; -use terminal_size::{Width, terminal_size}; +use terminal_size::{terminal_size, Width}; #[derive(Debug, Deserialize)] #[serde(tag = "message_type")] @@ -14,17 +14,11 @@ pub enum JsonLineMessage { query_label: Option, }, #[serde(rename = "DATA")] - Data { - data: Vec>, - }, + Data { data: Vec> }, #[serde(rename = "FINISH_SUCCESSFULLY")] - FinishSuccessfully { - statistics: Option, - }, + FinishSuccessfully { statistics: Option }, #[serde(rename = "FINISH_WITH_ERRORS")] - FinishWithErrors { - errors: Vec, - }, + FinishWithErrors { errors: Vec }, } #[derive(Debug, Deserialize)] @@ -84,7 +78,7 @@ pub fn parse_jsonlines_compact(text: &str) -> Result]) -> String { +pub fn render_table(columns: &[ResultColumn], rows: &[Vec], max_value_length: usize) -> String { let mut table = Table::new(); // Enable dynamic content arrangement for automatic wrapping @@ -98,11 +92,7 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec]) -> String { // Add headers with styling let header_cells: Vec = columns .iter() - .map(|col| { - Cell::new(&col.name) - .fg(Color::Cyan) - .add_attribute(Attribute::Bold) - }) + .map(|col| Cell::new(&col.name).fg(Color::Cyan).add_attribute(Attribute::Bold)) .collect(); table.set_header(header_cells); @@ -111,7 +101,16 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec]) -> String { for row in rows { let row_cells: Vec = row .iter() - .map(|val| Cell::new(format_value(val))) + .map(|val| { + let value_str = format_value(val); + // Truncate strings exceeding max_value_length + let display_value = if value_str.len() > max_value_length { + format!("{}...", &value_str[..max_value_length.saturating_sub(3)]) + } else { + value_str + }; + Cell::new(display_value) + }) .collect(); table.add_row(row_cells); @@ -126,8 +125,273 @@ fn format_value(value: &Value) -> String { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), - Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "".to_string()), + Value::Array(_) | Value::Object(_) => { + let json_str = serde_json::to_string(value).unwrap_or_else(|_| "".to_string()); + // Truncate very long JSON (e.g., query_telemetry with hundreds of KB) + const MAX_JSON_LENGTH: usize = 1000; + if json_str.len() > MAX_JSON_LENGTH { + format!("{}... (truncated)", &json_str[..MAX_JSON_LENGTH]) + } else { + json_str + } + } + } +} + +/// Calculate the display width of a string, ignoring ANSI escape codes +fn display_width(s: &str) -> usize { + let mut width = 0; + let mut in_escape = false; + + for ch in s.chars() { + if ch == '\x1b' { + // Start of ANSI escape sequence + in_escape = true; + } else if in_escape { + // Inside escape sequence - check for end + if ch.is_ascii_alphabetic() { + // End of escape sequence (SGR codes end with a letter) + in_escape = false; + } + // Don't count characters inside escape sequences + } else { + // Regular character - count it + width += 1; + } + } + + width +} + +pub fn render_table_expanded(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> String { + const BORDER_OVERHEAD_PER_COL: usize = 3; // "│ " + " │" + const MIN_COL_WIDTH: usize = 5; // Absolute minimum to avoid squashing + + let mut output = String::new(); + let available_width = terminal_width as usize; + + for (row_idx, row) in rows.iter().enumerate() { + // Add row number header + let row_header = format!("╔═══ Row {} ", row_idx + 1); + let header_len = row_header.len(); + output.push_str(&row_header); + output.push_str(&"═".repeat(terminal_width.saturating_sub(header_len as u16) as usize - 1)); + output.push_str("╗\n"); + + // Calculate needed width for each column (max of column name and value) + let mut col_widths: Vec = Vec::new(); + for (col_idx, col) in columns.iter().enumerate() { + let col_name_width = col.name.len(); + let value_width = if col_idx < row.len() { + let value_str = format_value(&row[col_idx]); + let truncated = if value_str.len() > max_value_length { + max_value_length + } else { + value_str.len() + }; + truncated + } else { + 0 + }; + // Use the larger of column name or value width, with a minimum + col_widths.push(col_name_width.max(value_width).max(MIN_COL_WIDTH)); + } + + // Dynamically chunk columns based on actual width requirements + let mut column_chunks: Vec> = Vec::new(); + let mut current_chunk: Vec = Vec::new(); + let mut current_width: usize = 4; // Start with table border overhead + + for (col_idx, &width) in col_widths.iter().enumerate() { + let col_with_border = width + BORDER_OVERHEAD_PER_COL; + + // Check if adding this column would exceed available width + if !current_chunk.is_empty() && (current_width + col_with_border > available_width) { + // Start a new chunk + column_chunks.push(current_chunk); + current_chunk = vec![col_idx]; + current_width = 4 + col_with_border; + } else { + // Add to current chunk + current_chunk.push(col_idx); + current_width += col_with_border; + } + } + + // Don't forget the last chunk + if !current_chunk.is_empty() { + column_chunks.push(current_chunk); + } + + for (chunk_idx, col_indices) in column_chunks.iter().enumerate() { + // Reuse the widths we already calculated for chunking + let min_widths: Vec = col_indices.iter().map(|&idx| col_widths[idx]).collect(); + + // Calculate total content width needed + let total_content_width: usize = min_widths.iter().sum(); + let num_cols = col_indices.len(); + + // Total table width = borders (4) + columns content + column separators (3 per column) + let total_min_width = 4 + total_content_width + (num_cols * 3); + + // Calculate extra space to distribute (make table exactly terminal_width) + let target_total_width = available_width; + let extra_space = if total_min_width < target_total_width { + target_total_width - total_min_width + } else { + 0 + }; + + // Distribute extra space proportionally among columns + let extra_per_col = if num_cols > 0 { extra_space / num_cols } else { 0 }; + let remainder = if num_cols > 0 { extra_space % num_cols } else { 0 }; + + // Create a mini table for this chunk + let mut table = Table::new(); + table.set_width(terminal_width); + // Use Dynamic arrangement to ensure content wraps within the terminal width + table.set_content_arrangement(ContentArrangement::Dynamic); + + // Add header row for this chunk + let header_cells: Vec = col_indices + .iter() + .map(|&idx| Cell::new(&columns[idx].name).fg(Color::Cyan).add_attribute(Attribute::Bold)) + .collect(); + table.set_header(header_cells); + + // Add value row for this chunk + let value_cells: Vec = col_indices + .iter() + .map(|&idx| { + let value_str = if idx < row.len() { format_value(&row[idx]) } else { String::new() }; + // Truncate strings exceeding max_value_length + // Let comfy-table handle wrapping for reasonable lengths + let display_value = if value_str.len() > max_value_length { + format!("{}...", &value_str[..max_value_length.saturating_sub(3)]) + } else { + value_str + }; + Cell::new(display_value) + }) + .collect(); + table.add_row(value_cells); + + // Set column constraints to ensure content fits within terminal width + // For single-column chunks with very wide content, limit to a reasonable width + if col_indices.len() == 1 && min_widths[0] > available_width / 2 { + // Single column with very wide content - set max width to allow wrapping + if let Some(column) = table.column_mut(0) { + // Use most of available width for the content column + let max_col_width = available_width.saturating_sub(10); // Leave room for borders + column.set_constraint(ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(max_col_width as u16))); + } + } else { + // Multiple columns or narrow content - distribute space evenly + for (i, &min_width) in min_widths.iter().enumerate() { + let extra = extra_per_col + if i < remainder { 1 } else { 0 }; + let target_width = min_width + extra; + if let Some(column) = table.column_mut(i) { + column.set_constraint(ColumnConstraint::Boundaries { + lower: ComfyWidth::Fixed(target_width as u16), + upper: ComfyWidth::Fixed(target_width as u16), + }); + } + } + } + + // Render this chunk + let table_str = table.to_string(); + + // For the first chunk, skip the top border (we have our custom header) + // For subsequent chunks, include everything + let start_line = if chunk_idx == 0 { 1 } else { 0 }; + + for (line_idx, line) in table_str.lines().enumerate() { + if line_idx >= start_line { + // Pad the line to terminal width for alignment + // Use display_width to ignore ANSI escape codes + let line_len = display_width(line); + if line_len < available_width { + // For lines ending with '+', extend with appropriate border character + let padded_line = if line.ends_with('+') { + // Determine the border character from the line + let pad_char = if line.contains('═') { + '═' + } else if line.contains('-') { + '-' + } else { + '-' + }; + let padding = pad_char.to_string().repeat(available_width - line_len); + format!("{}{}", &line[..line.len() - 1], padding) + "+" + } else { + // For content lines, pad with spaces + format!("{}{}", line, " ".repeat(available_width - line_len)) + }; + output.push_str(&padded_line); + } else { + output.push_str(line); + } + output.push('\n'); + } + } + } + + // Add spacing between result rows for better visual separation + if row_idx < rows.len() - 1 { + output.push('\n'); + output.push('\n'); + output.push('\n'); + } + } + + output +} + +pub fn should_use_expanded_mode(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> bool { + const MAX_HORIZONTAL_COLUMNS: usize = 15; + const BORDER_OVERHEAD_PER_COL: usize = 3; + const MIN_COL_WIDTH: usize = 5; + + let num_columns = columns.len(); + let available_width = terminal_width as usize; + + // Rule 1: Too many columns (more than 15 is definitely too many for horizontal) + if num_columns > MAX_HORIZONTAL_COLUMNS { + return true; + } + + // Rule 2: Content-aware check - calculate if all columns can fit horizontally + // Use the same logic as expanded mode to calculate actual widths needed + if !rows.is_empty() { + let row = &rows[0]; + + // Calculate needed width for each column (applying same truncation as rendering) + let mut total_width = 4; // Table borders + for (col_idx, col) in columns.iter().enumerate() { + let col_name_width = col.name.len(); + let value_width = if col_idx < row.len() { + let value_str = format_value(&row[col_idx]); + // Apply the same truncation logic as rendering to be consistent + if value_str.len() > max_value_length { + max_value_length + } else { + value_str.len() + } + } else { + 0 + }; + let needed_width = col_name_width.max(value_width).max(MIN_COL_WIDTH); + total_width += needed_width + BORDER_OVERHEAD_PER_COL; + } + + // If all columns don't fit, use expanded mode + if total_width > available_width { + return true; + } } + + false } #[cfg(test)] @@ -176,4 +440,215 @@ mod tests { fn test_format_value_string() { assert_eq!(format_value(&Value::String("test".to_string())), "test"); } + + #[test] + fn test_render_expanded_single_row() { + let columns = vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "name".to_string(), + column_type: "text".to_string(), + }, + ResultColumn { + name: "status".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![vec![ + Value::Number(1.into()), + Value::String("Alice".to_string()), + Value::String("active".to_string()), + ]]; + + let output = render_table_expanded(&columns, &rows, 80, 1000); + + // Check for row header + assert!(output.contains("╔═══ Row 1")); + + // Check for column headers (should appear before values) + assert!(output.contains("id")); + assert!(output.contains("name")); + assert!(output.contains("status")); + + // Check for values + assert!(output.contains("Alice")); + assert!(output.contains("active")); + + // Verify structure: headers should appear before values in the output + let id_pos = output.find("id").unwrap(); + let alice_pos = output.find("Alice").unwrap(); + assert!(id_pos < alice_pos, "Column names should appear before values"); + } + + #[test] + fn test_render_expanded_multiple_rows() { + let columns = vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "value".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![ + vec![Value::Number(1.into()), Value::String("A".to_string())], + vec![Value::Number(2.into()), Value::String("B".to_string())], + ]; + + let output = render_table_expanded(&columns, &rows, 80, 1000); + assert!(output.contains("╔═══ Row 1")); + assert!(output.contains("╔═══ Row 2")); + + // Each row should have its own header line + assert_eq!(output.matches("╔═══ Row").count(), 2); + } + + #[test] + fn test_value_truncation() { + let columns = vec![ + ResultColumn { + name: "short".to_string(), + column_type: "text".to_string(), + }, + ResultColumn { + name: "very_long".to_string(), + column_type: "text".to_string(), + }, + ]; + + // Create a very long string (>1000 chars) that should be truncated + let long_string = "a".repeat(1500); + let rows = vec![vec![Value::String("ok".to_string()), Value::String(long_string.clone())]]; + + let output = render_table_expanded(&columns, &rows, 80, 1000); + + // The very long string should be truncated with "..." + assert!(output.contains("...")); + assert!(!output.contains(&long_string)); // Full string should not appear + + // But strings under 1000 chars should NOT be truncated (no "..." marker) + let medium_string = "b".repeat(200); + let rows2 = vec![vec![Value::String("ok".to_string()), Value::String(medium_string.clone())]]; + let output2 = render_table_expanded(&columns, &rows2, 80, 1000); + + // Medium string should not be truncated (no "..." marker) + // Note: comfy-table may wrap it, so we just check it wasn't truncated + assert!(!output2.contains("bbb...")); + assert!(output2.contains("ok")); // At least verify basic rendering works + } + + #[test] + fn test_json_truncation() { + // Create a very large JSON array + let large_array = Value::Array(vec![Value::String("x".repeat(500)); 10]); + let rows = vec![vec![large_array]]; + + let formatted = format_value(&rows[0][0]); + + // Should be truncated + assert!(formatted.contains("truncated") || formatted.len() < 5000); + } + + #[test] + fn test_should_use_expanded_mode() { + let columns = vec![ + ResultColumn { + name: "col1".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "col2".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![vec![Value::Number(1.into()), Value::String("test".to_string())]]; + + // Wide terminal, few columns -> horizontal + assert!(!should_use_expanded_mode(&columns, &rows, 150, 10000)); + + // Many columns with longer names -> expanded + let many_columns: Vec = (0..20) + .map(|i| ResultColumn { + name: format!("column_name_{}", i), + column_type: "int".to_string(), + }) + .collect(); + let many_cols_row = vec![vec![Value::Number(1.into()); 20]]; + assert!(should_use_expanded_mode(&many_columns, &many_cols_row, 150, 10000)); + + // Narrow terminal with many columns -> expanded + let five_columns = vec![ + ResultColumn { + name: "a".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "b".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "c".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "d".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "e".to_string(), + column_type: "int".to_string(), + }, + ]; + let five_cols_row = vec![vec![Value::Number(1.into()); 5]]; + assert!(should_use_expanded_mode(&five_columns, &five_cols_row, 40, 10000)); + } + + #[test] + fn test_content_too_wide_detection() { + let columns = vec![ + ResultColumn { + name: "column_one_with_long_name".to_string(), + column_type: "text".to_string(), + }, + ResultColumn { + name: "column_two_with_long_name".to_string(), + column_type: "text".to_string(), + }, + ResultColumn { + name: "column_three_also_long".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![vec![ + Value::String("this is a fairly long value".to_string()), + Value::String("another long value here".to_string()), + Value::String("and yet another long value".to_string()), + ]]; + + // Should detect that columns won't fit and use expanded mode + assert!(should_use_expanded_mode(&columns, &rows, 80, 10000)); + } + + #[test] + fn test_display_width() { + // Plain text + assert_eq!(display_width("hello"), 5); + + // Text with ANSI color codes (cyan) + assert_eq!(display_width("\x1b[36mhello\x1b[0m"), 5); + + // Text with ANSI bold + color + assert_eq!(display_width("\x1b[1m\x1b[36mhello\x1b[0m"), 5); + + // Empty string + assert_eq!(display_width(""), 0); + + // Only ANSI codes + assert_eq!(display_width("\x1b[36m\x1b[1m\x1b[0m"), 0); + } } diff --git a/tests/cli.rs b/tests/cli.rs index df2ffba..7916bc3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -297,3 +297,35 @@ fn test_auto_format() { assert!(stdout.contains("name")); assert!(stdout.contains("test")); } + +#[test] +fn test_expanded_format() { + let (success, stdout, _) = run_fb(&["--core", "--format=expanded", "SELECT 1 as id, 'test' as name"]); + assert!(success); + assert!(stdout.contains("╔═══ Row 1")); + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); + assert!(stdout.contains("test")); +} + +#[test] +fn test_wide_table_auto_expanded() { + // Query with many columns should automatically use expanded mode + let (success, stdout, _) = run_fb(&[ + "--core", + "--format=auto", + "SELECT 1 as a, 2 as b, 3 as c, 4 as d, 5 as e, 6 as f, \ + 7 as g, 8 as h, 9 as i, 10 as j, 11 as k, 12 as l, 13 as m", + ]); + assert!(success); + assert!(stdout.contains("╔═══ Row 1")); // Should auto-switch to expanded +} + +#[test] +fn test_narrow_table_stays_horizontal() { + // Query with few columns should stay horizontal + let (success, stdout, _) = run_fb(&["--core", "--format=auto", "SELECT 1 as id, 'test' as name"]); + assert!(success); + assert!(!stdout.contains("╔═══ Row 1")); // Should NOT use expanded + assert!(stdout.contains("id")); // But still contains data +} From d6a976117ebd2525cad707e092fc25b8cacbcfc4 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 13:35:34 +0100 Subject: [PATCH 003/147] Reduce visual clutter in expanded mode - Use LowerBoundary constraints instead of fixed boundaries for multi-column chunks to prevent unnecessary content wrapping - Skip bottom border of non-last chunks to eliminate double borders between chunks within the same row - Improves readability by ensuring column names display on single lines and reducing repetitive border lines Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index d54cbb5..f337710 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -286,28 +286,37 @@ pub fn render_table_expanded(columns: &[ResultColumn], rows: &[Vec], term column.set_constraint(ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(max_col_width as u16))); } } else { - // Multiple columns or narrow content - distribute space evenly + // Multiple columns or narrow content - set minimum width to prevent wrapping + // but allow columns to be wider if needed for proper layout for (i, &min_width) in min_widths.iter().enumerate() { let extra = extra_per_col + if i < remainder { 1 } else { 0 }; let target_width = min_width + extra; if let Some(column) = table.column_mut(i) { - column.set_constraint(ColumnConstraint::Boundaries { - lower: ComfyWidth::Fixed(target_width as u16), - upper: ComfyWidth::Fixed(target_width as u16), - }); + // Use LowerBoundary instead of fixed boundaries to prevent content wrapping + column.set_constraint(ColumnConstraint::LowerBoundary(ComfyWidth::Fixed(target_width as u16))); } } } // Render this chunk let table_str = table.to_string(); + let lines: Vec<&str> = table_str.lines().collect(); + let num_lines = lines.len(); // For the first chunk, skip the top border (we have our custom header) // For subsequent chunks, include everything let start_line = if chunk_idx == 0 { 1 } else { 0 }; - for (line_idx, line) in table_str.lines().enumerate() { + // Check if this is the last chunk + let is_last_chunk = chunk_idx == column_chunks.len() - 1; + + for (line_idx, line) in lines.iter().enumerate() { if line_idx >= start_line { + // Skip the bottom border for non-last chunks to reduce visual clutter + if !is_last_chunk && line_idx == num_lines - 1 { + continue; + } + // Pad the line to terminal width for alignment // Use display_width to ignore ANSI escape codes let line_len = display_width(line); From 7c1c67e655b7c682c5813d01d0719225751e4606 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 13:40:20 +0100 Subject: [PATCH 004/147] Swap border styles in expanded mode for better visual hierarchy - Use lighter '--' borders for header separators (between column names and values) - Use heavier '==' borders for chunk separators (between different column groups) - This emphasizes the separation between chunks while keeping headers lighter Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index f337710..202dba5 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -312,34 +312,45 @@ pub fn render_table_expanded(columns: &[ResultColumn], rows: &[Vec], term for (line_idx, line) in lines.iter().enumerate() { if line_idx >= start_line { - // Skip the bottom border for non-last chunks to reduce visual clutter - if !is_last_chunk && line_idx == num_lines - 1 { - continue; + // Swap border characters for better visual hierarchy: + // - Use '-' for header separator (lighter, less prominent) + // - Use '=' for chunk separator (heavier, more prominent) + let mut processed_line = line.to_string(); + if line.starts_with('+') && line.contains('=') { + // Header separator line: change = to - + processed_line = processed_line.replace('=', "-"); + } else if line.starts_with('+') && line.contains('-') && line_idx == num_lines - 1 { + // Bottom border: change - to = for non-last chunks to emphasize separation + if !is_last_chunk { + processed_line = processed_line.replace('-', "="); + } } // Pad the line to terminal width for alignment // Use display_width to ignore ANSI escape codes - let line_len = display_width(line); + let line_len = display_width(&processed_line); if line_len < available_width { // For lines ending with '+', extend with appropriate border character - let padded_line = if line.ends_with('+') { + let padded_line = if processed_line.ends_with('+') { // Determine the border character from the line - let pad_char = if line.contains('═') { + let pad_char = if processed_line.contains('═') { '═' - } else if line.contains('-') { + } else if processed_line.contains('=') { + '=' + } else if processed_line.contains('-') { '-' } else { '-' }; let padding = pad_char.to_string().repeat(available_width - line_len); - format!("{}{}", &line[..line.len() - 1], padding) + "+" + format!("{}{}", &processed_line[..processed_line.len() - 1], padding) + "+" } else { // For content lines, pad with spaces - format!("{}{}", line, " ".repeat(available_width - line_len)) + format!("{}{}", processed_line, " ".repeat(available_width - line_len)) }; output.push_str(&padded_line); } else { - output.push_str(line); + output.push_str(&processed_line); } output.push('\n'); } From f6720d2e8adc25593325fa69367b15f57fca25c4 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 13:42:30 +0100 Subject: [PATCH 005/147] Move heavy separator to top of chunks in expanded mode - Use '==' borders at the top of each chunk (except first) to emphasize new section - Use '--' borders for bottom, header separators, and internal structure - Creates clearer visual separation where each chunk begins Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 202dba5..2aa3650 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -314,16 +314,14 @@ pub fn render_table_expanded(columns: &[ResultColumn], rows: &[Vec], term if line_idx >= start_line { // Swap border characters for better visual hierarchy: // - Use '-' for header separator (lighter, less prominent) - // - Use '=' for chunk separator (heavier, more prominent) + // - Use '=' for chunk separator at top of non-first chunks (heavier, more prominent) let mut processed_line = line.to_string(); if line.starts_with('+') && line.contains('=') { // Header separator line: change = to - processed_line = processed_line.replace('=', "-"); - } else if line.starts_with('+') && line.contains('-') && line_idx == num_lines - 1 { - // Bottom border: change - to = for non-last chunks to emphasize separation - if !is_last_chunk { - processed_line = processed_line.replace('-', "="); - } + } else if line.starts_with('+') && line.contains('-') && line_idx == 0 && chunk_idx > 0 { + // Top border of non-first chunks: change - to = to emphasize separation + processed_line = processed_line.replace('-', "="); } // Pad the line to terminal width for alignment From 986b554fc19e14c4d2d168ee61a1faa2d8c34adb Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 13:43:55 +0100 Subject: [PATCH 006/147] Skip redundant bottom borders between chunks - Remove bottom border of non-last chunks since the next chunk's top border provides separation - Reduces visual clutter by having only one separator line (==) between chunks - Last chunk still has bottom border for proper closure Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 2aa3650..89cf282 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -312,6 +312,11 @@ pub fn render_table_expanded(columns: &[ResultColumn], rows: &[Vec], term for (line_idx, line) in lines.iter().enumerate() { if line_idx >= start_line { + // Skip the bottom border for non-last chunks since the next chunk's top border provides separation + if !is_last_chunk && line_idx == num_lines - 1 { + continue; + } + // Swap border characters for better visual hierarchy: // - Use '-' for header separator (lighter, less prominent) // - Use '=' for chunk separator at top of non-first chunks (heavier, more prominent) From befca64ed39ec1777bef5a1611770d5f03b883e3 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 14:41:21 +0100 Subject: [PATCH 007/147] Add interactive csvlens viewer for query results Integrate csvlens library to provide an interactive viewer for query results in REPL mode. Users can type \view or press Ctrl+V (then Enter) to open the last query result in a full-screen viewer with vim-like navigation, search, and scrolling capabilities. Features: - Store last query result in Context for viewing - Convert query results to CSV format with proper escaping - Launch csvlens viewer with temporary CSV file - Support both \view text command and Ctrl+V keybind - Add \help command to show available commands - Handle error cases gracefully (no results, query errors, empty data) Implementation: - New viewer module (src/viewer.rs) for csvlens integration - CSV conversion functions in table_renderer with RFC 4180 escaping - Temporary file creation using process ID for uniqueness - Command detection in REPL loop for \view and \help - Ctrl+V keybind inserts \view command (user presses Enter to execute) Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 119 ++++ Cargo.lock | 1376 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/context.rs | 13 +- src/main.rs | 37 +- src/query.rs | 3 + src/table_renderer.rs | 100 ++- src/utils.rs | 16 + src/viewer.rs | 170 +++++ 9 files changed, 1818 insertions(+), 17 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/viewer.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9e9245 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`fb-cli` is a command-line client for Firebolt database written in Rust. It supports both single-query execution and an interactive REPL mode with query history and dynamic parameter configuration. + +**Note**: This project has been moved to https://github.com/firebolt-db/fb-cli + +## Build and Development Commands + +### Building +```bash +cargo build --release +``` + +### Testing +```bash +# Run all tests +cargo test + +# Run specific test +cargo test test_name + +# Run tests with output +cargo test -- --nocapture + +# Integration tests only +cargo test --test cli +``` + +### Installation +```bash +cargo install --path . --locked +``` + +### Formatting +```bash +cargo fmt +``` + +### Linting +```bash +cargo clippy +``` + +## Architecture + +### Module Structure + +- **main.rs**: Entry point, handles REPL mode with rustyline for line editing and history +- **args.rs**: Command-line argument parsing with gumdrop, URL generation, and default config management +- **context.rs**: Application state container holding Args and ServiceAccountToken +- **query.rs**: HTTP query execution, set/unset parameter handling, and SQL query splitting using Pest parser +- **auth.rs**: OAuth2 service account authentication with token caching +- **utils.rs**: File paths (~/.firebolt/), spinner animation, and time formatting utilities +- **sql.pest**: Pest grammar for parsing SQL queries (handles strings, comments, semicolons) + +### Key Design Patterns + +**Context Management**: The `Context` struct is passed mutably through the application, allowing runtime updates to Args and URL as users `set` and `unset` parameters in REPL mode. + +**Query Splitting**: SQL queries are parsed using a Pest grammar (sql.pest) that correctly handles: +- String literals (single quotes with escape sequences) +- E-strings (PostgreSQL-style escaped strings) +- Raw strings ($$...$$) +- Quoted identifiers (double quotes) +- Line comments (--) and nested block comments (/* /* */ */) +- Semicolons as query terminators (but not within strings/comments) + +**Authentication Flow**: +1. Service Account tokens are cached in `~/.firebolt/fb_sa_token` +2. Tokens are valid for 30 minutes and automatically reused if valid +3. JWT can be loaded from `~/.firebolt/jwt` with `--jwt-from-file` +4. Bearer tokens can be passed directly with `--bearer` + +**Configuration Persistence**: Defaults are stored in `~/.firebolt/fb_config` as YAML. When `--update-defaults` is used, current arguments (excluding update_defaults itself) are saved and merged with future invocations. + +**Dynamic Parameter Updates**: The REPL supports `set key=value` and `unset key` commands to modify query parameters at runtime without restarting the CLI. Server can also update parameters via `firebolt-update-parameters` and `firebolt-remove-parameters` headers. + +### HTTP Protocol + +- Uses reqwest with HTTP/2 keep-alive (3600s timeout, 60s intervals) +- Sends queries as POST body to constructed URL with query parameters +- Headers: `user-agent`, `Firebolt-Protocol-Version: 2.3`, `authorization` (Bearer token) +- Handles special response headers: `firebolt-update-parameters`, `firebolt-remove-parameters`, `firebolt-update-endpoint` + +### URL Construction + +URLs are built from: +- Protocol: `http` for localhost, `https` otherwise +- Host: from `--host` or defaults +- Database: from `--database` or `--extra database=...` +- Format: from `--format` or `--extra format=...` +- Extra parameters: from `--extra` (URL-encoded once during normalize_extras) +- Query label: from `--label` +- Advanced mode: automatically added for non-localhost + +### Firebolt Core vs Standard + +- **Core mode** (`--core`): Connects to Firebolt Core at `localhost:3473`, database `firebolt`, format `PSQL`, no JWT +- **Standard mode** (default): Connects to `localhost:8123` (or 9123 with JWT), database `local_dev_db`, format `PSQL` + +## Testing Considerations + +- Unit tests are in-module (`#[cfg(test)] mod tests`) +- Integration tests in `tests/cli.rs` use `CARGO_BIN_EXE_fb` to test the compiled binary +- SQL parsing tests extensively cover edge cases: nested comments, strings with semicolons, escape sequences +- Auth tests are minimal due to external service dependencies + +## Important Behavioral Details + +- **URL encoding**: Parameters are encoded once during `normalize_extras(encode: true)`. Subsequent calls with `encode: false` prevent double-encoding. +- **REPL multi-line**: Press Ctrl+O to insert newline. Queries must end with semicolon. +- **Ctrl+C in REPL**: Cancels current input but doesn't exit +- **Ctrl+D in REPL**: Exits (EOF) +- **Spinner**: Shown during query execution unless `--no-spinner` or `--concise` +- **History**: Saved to `~/.firebolt/fb_history` (max 10,000 entries), supports Ctrl+R search diff --git a/Cargo.lock b/Cargo.lock index 2196048..d0eba49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,12 +25,299 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi-to-tui" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom 7.1.3", + "ratatui", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win 5.4.1", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arrow" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num", +] + +[[package]] +name = "arrow-array" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.16.1", + "num", +] + +[[package]] +name = "arrow-buffer" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" +dependencies = [ + "bytes", + "half", + "num", +] + +[[package]] +name = "arrow-cast" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "half", + "lexical-core", + "num", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa9bf02705b5cf762b6f764c65f04ae9082c7cfc4e96e0c33548ee3f67012eb" +dependencies = [ + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num", +] + +[[package]] +name = "arrow-ord" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" + +[[package]] +name = "arrow-select" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", +] + +[[package]] +name = "arrow-string" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -50,12 +351,33 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.55" @@ -72,27 +394,128 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size 0.4.3", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "clipboard-win" version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" dependencies = [ - "error-code", + "error-code 2.3.1", "str-buf", "winapi", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code 3.3.2", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "comfy-table" version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e959d788268e3bf9d35ace83e81b124190378e4c91c9067524675e33394b8ba" dependencies = [ - "crossterm", - "strum", - "strum_macros", - "unicode-width", + "crossterm 0.26.1", + "strum 0.24.1", + "strum_macros 0.24.3", + "unicode-width 0.1.14", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", ] [[package]] @@ -136,6 +559,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "filedescriptor", + "mio 1.1.1", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -145,6 +585,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -155,6 +601,84 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "csvlens" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721f1b47a845e16645f6ac0fac2227e04a7896a484116f9d831305ceb4d07a7c" +dependencies = [ + "ansi-to-tui", + "anyhow", + "arboard", + "arrow", + "clap", + "crossterm 0.28.1", + "csv", + "qsv-sniffer", + "ratatui", + "regex", + "sorted-vec", + "tempfile", + "terminal-colorsaurus", + "thiserror 2.0.18", + "tui-input", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + [[package]] name = "digest" version = "0.10.7" @@ -186,6 +710,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -197,6 +731,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -238,6 +784,18 @@ dependencies = [ "str-buf", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + [[package]] name = "fastrand" version = "2.3.0" @@ -249,6 +807,7 @@ name = "fb" version = "0.2.3" dependencies = [ "comfy-table", + "csvlens", "dirs", "gumdrop", "home 0.4.2", @@ -262,7 +821,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "terminal_size", + "terminal_size 0.3.0", "tokio", "tokio-util", "toml", @@ -280,18 +839,41 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -366,6 +948,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -428,6 +1020,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -440,6 +1055,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "home" version = "0.4.2" @@ -577,6 +1198,30 @@ dependencies = [ "windows-registry", ] +[[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.1.1" @@ -658,6 +1303,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -686,7 +1337,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -705,6 +1378,21 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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.17" @@ -721,12 +1409,75 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" @@ -770,6 +1521,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.7.6" @@ -782,6 +1542,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "0.8.11" @@ -801,6 +1567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -842,12 +1609,184 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.75" @@ -908,6 +1847,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -931,6 +1880,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -980,6 +1935,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1016,6 +1982,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qsv-dateparser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6030a42cfbad8f656c7c16b027e0957d85dc0b43365a88d830834de582d7a603" +dependencies = [ + "anyhow", + "chrono", + "fast-float2", + "regex", +] + +[[package]] +name = "qsv-sniffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b25b79fc637d5ec0a9d72612207f48676b39c8b5c48ab32cfa0d47915fd664a" +dependencies = [ + "bitflags 2.10.0", + "bytecount", + "csv", + "csv-core", + "hashbrown 0.15.5", + "memchr", + "qsv-dateparser", + "regex", + "simdutf8", + "tabwriter", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.44" @@ -1041,6 +2046,27 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1058,7 +2084,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1217,7 +2243,7 @@ checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" dependencies = [ "bitflags 2.10.0", "cfg-if", - "clipboard-win", + "clipboard-win 4.5.0", "fd-lock", "home 0.5.12", "libc", @@ -1228,7 +2254,7 @@ dependencies = [ "regex", "scopeguard", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "utf8parse", "winapi", ] @@ -1389,6 +2415,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio 0.8.11", + "mio 1.1.1", "signal-hook", ] @@ -1402,6 +2429,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1424,37 +2457,77 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sorted-vec" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f58d7b0190c7f12df7e8be6b79767a0836059159811b869d5ab55721fe14d0" + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "str-buf" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum_macros" version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "rustversion", "syn 1.0.109", ] +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1524,6 +2597,15 @@ dependencies = [ "libc", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -1537,6 +2619,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal-colorsaurus" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" +dependencies = [ + "cfg-if", + "libc", + "memchr", + "mio 1.1.1", + "terminal-trx", + "windows-sys 0.61.2", + "xterm-color", +] + +[[package]] +name = "terminal-trx" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "terminal_size" version = "0.3.0" @@ -1547,13 +2655,32 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.3", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1567,6 +2694,26 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1743,12 +2890,34 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-input" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f679521b7fd35e17fbca58ec5aac64c5d331e54a9034510ec26b193ffd7597" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1773,12 +2942,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1916,6 +3102,76 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.3", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.3", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -1948,6 +3204,41 @@ 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 2.0.114", +] + +[[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 2.0.114", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2229,12 +3520,53 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.3", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xterm-color" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7008a9d8ba97a7e47d9b2df63fcdb8dade303010c5a7cd5bf2469d4da6eba673" + [[package]] name = "yoke" version = "0.8.1" @@ -2258,6 +3590,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 8f466fb..3283219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,4 @@ pest_derive = "2.7" terminal_size = "0.3" comfy-table = "6.2" home = "< 0.5.12" +csvlens = "0.14" diff --git a/src/context.rs b/src/context.rs index b9b868c..76eda49 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,4 +1,5 @@ use crate::args::{get_url, Args}; +use crate::table_renderer::ParsedResult; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -16,12 +17,21 @@ pub struct Context { pub prompt1: Option, pub prompt2: Option, pub prompt3: Option, + pub last_result: Option, } impl Context { pub fn new(args: Args) -> Self { let url = get_url(&args); - Self { args, url, sa_token: None, prompt1: None, prompt2: None, prompt3: None } + Self { + args, + url, + sa_token: None, + prompt1: None, + prompt2: None, + prompt3: None, + last_result: None, + } } pub fn update_url(&mut self) { @@ -56,5 +66,6 @@ mod tests { assert!(context.url.contains("localhost:8123")); assert!(context.url.contains("database=test_db")); assert!(context.sa_token.is_none()); + assert!(context.last_result.is_none()); } } diff --git a/src/main.rs b/src/main.rs index e62d5fc..f844a5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod meta_commands; mod query; mod table_renderer; mod utils; +mod viewer; use args::get_args; use auth::maybe_authenticate; @@ -15,6 +16,7 @@ use context::Context; use meta_commands::handle_meta_command; use query::{query, try_split_queries}; use utils::history_path; +use viewer::open_csvlens_viewer; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); @@ -58,8 +60,16 @@ async fn main() -> Result<(), Box> { rl.bind_sequence(KeyEvent(KeyCode::Char('o'), Modifiers::CTRL), EventHandler::Simple(Cmd::Newline)); - if is_tty { - eprintln!("Press Ctrl+D to exit."); + // Bind Ctrl-V to trigger viewer via special marker + // Using Cmd::AcceptLine alone won't work because we need to detect it was Ctrl-V + // Instead, we'll keep the two-step approach (Ctrl-V + Enter) which is explicit and clear + rl.bind_sequence( + KeyEvent(KeyCode::Char('v'), Modifiers::CTRL), + EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())) + ); + + if is_tty && !context.args.concise { + eprintln!("Type \\help for available commands or press Ctrl+V to view last result. Ctrl+D to exit."); } let mut buffer: String = String::new(); let mut has_error = false; @@ -93,6 +103,29 @@ async fn main() -> Result<(), Box> { match readline { Ok(line) => { + // Check for special commands + let trimmed = line.trim(); + + if trimmed == "\\view" { + // Open csvlens viewer for last query result + if let Err(e) = open_csvlens_viewer(&context) { + eprintln!("Failed to open viewer: {}", e); + } + continue; + } else if trimmed == "\\help" { + // Show help for special commands + eprintln!("Special commands:"); + eprintln!(" \\view - Open last query result in interactive csvlens viewer"); + eprintln!(" \\help - Show this help message"); + eprintln!(); + eprintln!("Keyboard shortcuts:"); + eprintln!(" Ctrl+V - Open last query result in csvlens viewer (same as \\view)"); + eprintln!(" Ctrl+O - Insert newline (for multi-line queries)"); + eprintln!(" Ctrl+D - Exit REPL"); + eprintln!(" Ctrl+C - Cancel current input"); + continue; + } + buffer += line.as_str(); if buffer.trim() == "quit" || buffer.trim() == "exit" { diff --git a/src/query.rs b/src/query.rs index 5582ce7..8026504 100644 --- a/src/query.rs +++ b/src/query.rs @@ -200,6 +200,9 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if context.args.should_render_table() { match table_renderer::parse_jsonlines_compact(&body) { Ok(parsed) => { + // Store result for interactive viewing + context.last_result = Some(parsed.clone()); + if let Some(errors) = parsed.errors { // Display errors for error in errors { diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 89cf282..f020de1 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -21,18 +21,19 @@ pub enum JsonLineMessage { FinishWithErrors { errors: Vec }, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct ResultColumn { pub name: String, #[serde(rename = "type")] pub column_type: String, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct ErrorDetail { pub description: String, } +#[derive(Clone, Debug)] pub struct ParsedResult { pub columns: Vec, pub rows: Vec>, @@ -417,6 +418,54 @@ pub fn should_use_expanded_mode(columns: &[ResultColumn], rows: &[Vec], t false } +/// Format a Value for CSV output +fn format_value_csv(val: &Value) -> String { + match val { + Value::Null => String::new(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + Value::Array(_) | Value::Object(_) => val.to_string(), + } +} + +/// Escape a CSV field according to RFC 4180 +fn escape_csv_field(field: &str) -> String { + // Escape fields containing comma, quote, or newline + if field.contains(',') || field.contains('"') || field.contains('\n') { + format!("\"{}\"", field.replace('"', "\"\"")) + } else { + field.to_string() + } +} + +/// Convert ParsedResult to CSV format +pub fn write_result_as_csv( + writer: &mut W, + columns: &[ResultColumn], + rows: &[Vec], +) -> Result<(), Box> { + // Write CSV header + let header = columns + .iter() + .map(|col| escape_csv_field(&col.name)) + .collect::>() + .join(","); + writeln!(writer, "{}", header)?; + + // Write data rows + for row in rows { + let row_str = row + .iter() + .map(|val| escape_csv_field(&format_value_csv(val))) + .collect::>() + .join(","); + writeln!(writer, "{}", row_str)?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -674,4 +723,51 @@ mod tests { // Only ANSI codes assert_eq!(display_width("\x1b[36m\x1b[1m\x1b[0m"), 0); } + + #[test] + fn test_write_result_as_csv() { + let columns = vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "name".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![ + vec![Value::Number(1.into()), Value::String("Alice".to_string())], + vec![Value::Number(2.into()), Value::String("Bob".to_string())], + ]; + + let mut output = Vec::new(); + write_result_as_csv(&mut output, &columns, &rows).unwrap(); + + let csv_str = String::from_utf8(output).unwrap(); + assert!(csv_str.contains("id,name")); + assert!(csv_str.contains("1,Alice")); + assert!(csv_str.contains("2,Bob")); + } + + #[test] + fn test_csv_escaping() { + let columns = vec![ResultColumn { + name: "col".to_string(), + column_type: "text".to_string(), + }]; + let rows = vec![ + vec![Value::String("has,comma".to_string())], + vec![Value::String("has\"quote".to_string())], + vec![Value::String("has\nnewline".to_string())], + ]; + + let mut output = Vec::new(); + write_result_as_csv(&mut output, &columns, &rows).unwrap(); + + let csv_str = String::from_utf8(output).unwrap(); + assert!(csv_str.contains("\"has,comma\"")); + assert!(csv_str.contains("\"has\"\"quote\"")); + assert!(csv_str.contains("\"has\nnewline\"")); + } } diff --git a/src/utils.rs b/src/utils.rs index 0a7c785..e9bb4a9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,4 @@ +use std::env; use std::fs; use std::io::stderr; use std::io::Write; @@ -32,6 +33,13 @@ pub fn sa_token_path() -> Result> { Ok(init_root_path()?.join("fb_sa_token")) } +/// Get path for temporary CSV file in system temp directory +pub fn temp_csv_path() -> Result> { + let mut path = env::temp_dir(); + path.push(format!("fb_result_{}.csv", std::process::id())); + Ok(path) +} + // Format remaining time for token validity pub fn format_remaining_time(time: SystemTime, maybe_more: String) -> Result> { let remaining = time.duration_since(SystemTime::now())?.as_secs(); @@ -83,4 +91,12 @@ mod tests { let sa_token = sa_token_path().unwrap(); assert!(sa_token.ends_with("fb_sa_token")); } + + #[test] + fn test_temp_csv_path() { + let path = temp_csv_path().unwrap(); + let file_name = path.file_name().unwrap().to_str().unwrap(); + assert!(file_name.starts_with("fb_result_")); + assert!(file_name.ends_with(".csv")); + } } diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..7deb377 --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,170 @@ +use crate::context::Context; +use crate::table_renderer::write_result_as_csv; +use crate::utils::temp_csv_path; +use std::fs::File; + +/// Open csvlens viewer for the last query result +pub fn open_csvlens_viewer(context: &Context) -> Result<(), Box> { + // Check if we have a result to display + let result = match &context.last_result { + Some(r) => r, + None => { + eprintln!("No query results to display. Run a query first."); + return Ok(()); + } + }; + + // Check for errors in last result + if let Some(ref errors) = result.errors { + eprintln!("Cannot display results - last query had errors:"); + for error in errors { + eprintln!(" {}", error.description); + } + return Ok(()); + } + + // Check if result is empty + if result.columns.is_empty() { + eprintln!("No data to display (no columns in result)."); + return Ok(()); + } + + if result.rows.is_empty() { + eprintln!("Query returned 0 rows. Nothing to display."); + return Ok(()); + } + + // Write result to temporary CSV file + let csv_path = temp_csv_path()?; + let mut file = File::create(&csv_path)?; + write_result_as_csv(&mut file, &result.columns, &result.rows)?; + drop(file); // Ensure file is flushed and closed + + if context.args.verbose { + eprintln!("Wrote result to: {:?}", csv_path); + eprintln!("Opening csvlens viewer... (press 'q' or ESC to exit)"); + } + + // Launch csvlens viewer + let csv_path_str = csv_path.to_string_lossy().to_string(); + let options = csvlens::CsvlensOptions { + filename: Some(csv_path_str), + ..Default::default() + }; + + match csvlens::run_csvlens_with_options(options) { + Ok(_) => Ok(()), + Err(e) => { + eprintln!("Error opening csvlens: {}", e); + Err(Box::new(e)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::table_renderer::{ErrorDetail, ParsedResult, ResultColumn}; + use serde_json::Value; + + #[test] + fn test_no_result_error() { + let args = crate::args::get_args().unwrap(); + let context = Context::new(args); + + // Should not panic, should return Ok with error message + let result = open_csvlens_viewer(&context); + assert!(result.is_ok()); + } + + #[test] + fn test_error_result() { + let args = crate::args::get_args().unwrap(); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![], + rows: vec![], + statistics: None, + errors: Some(vec![ErrorDetail { + description: "Test error".to_string(), + }]), + }); + + let result = open_csvlens_viewer(&context); + assert!(result.is_ok()); + } + + #[test] + fn test_empty_columns() { + let args = crate::args::get_args().unwrap(); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![], + rows: vec![], + statistics: None, + errors: None, + }); + + let result = open_csvlens_viewer(&context); + assert!(result.is_ok()); + } + + #[test] + fn test_empty_rows() { + let args = crate::args::get_args().unwrap(); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![ResultColumn { + name: "col1".to_string(), + column_type: "int".to_string(), + }], + rows: vec![], + statistics: None, + errors: None, + }); + + let result = open_csvlens_viewer(&context); + assert!(result.is_ok()); + } + + #[test] + fn test_csv_file_creation() { + let args = crate::args::get_args().unwrap(); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "name".to_string(), + column_type: "text".to_string(), + }, + ], + rows: vec![ + vec![Value::Number(1.into()), Value::String("Alice".to_string())], + vec![Value::Number(2.into()), Value::String("Bob".to_string())], + ], + statistics: None, + errors: None, + }); + + // This test verifies that the CSV file is created and written correctly + // We can't test the actual csvlens launch without a terminal, but we can + // verify the file creation part + let csv_path = temp_csv_path().unwrap(); + let mut file = File::create(&csv_path).unwrap(); + write_result_as_csv(&mut file, &context.last_result.as_ref().unwrap().columns, &context.last_result.as_ref().unwrap().rows).unwrap(); + drop(file); + + // Verify file exists and has content + let content = std::fs::read_to_string(&csv_path).unwrap(); + assert!(content.contains("id,name")); + assert!(content.contains("1,Alice")); + assert!(content.contains("2,Bob")); + + // Clean up + std::fs::remove_file(&csv_path).ok(); + } +} From 3718939f642a10f97797a34fbffaf7978dec431c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 15:20:55 +0100 Subject: [PATCH 008/147] Replace expanded mode with simpler vertical two-column format Replaces the complex chunked expanded mode with a cleaner vertical format that displays each row as a two-column table (column name | value). This eliminates ~145 lines of chunking logic while improving readability. Changes: - Replace render_table_expanded() with render_table_vertical() - Rename all "expanded" terminology to "vertical" throughout codebase - Update format option from --format=expanded to --format=vertical - Simplify row display: "Row N:" header with simple two-column table - Column names in cyan bold, values with natural wrapping - Update all tests to match new format Benefits: Simpler code, cleaner output, easier to scan, no chunk boundaries Co-Authored-By: Claude Sonnet 4.5 --- src/args.rs | 10 +- src/query.rs | 14 +- src/table_renderer.rs | 326 ++++++++++++------------------------------ tests/cli.rs | 10 +- 4 files changed, 107 insertions(+), 253 deletions(-) diff --git a/src/args.rs b/src/args.rs index a829400..fbbff48 100644 --- a/src/args.rs +++ b/src/args.rs @@ -38,7 +38,7 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub database: String, - #[options(help = "Output format (auto, expanded, PSQL, TabSeparatedWithNames, JSONLines_Compact, ...)")] + #[options(help = "Output format (auto, vertical, PSQL, TabSeparatedWithNames, JSONLines_Compact, ...)")] #[serde(default)] pub format: String, @@ -110,11 +110,11 @@ pub struct Args { impl Args { pub fn should_render_table(&self) -> bool { - self.format.eq_ignore_ascii_case("auto") || self.format.eq_ignore_ascii_case("expanded") + self.format.eq_ignore_ascii_case("auto") || self.format.eq_ignore_ascii_case("vertical") } - pub fn is_expanded_mode(&self) -> bool { - self.format.eq_ignore_ascii_case("expanded") + pub fn is_vertical_mode(&self) -> bool { + self.format.eq_ignore_ascii_case("vertical") } } @@ -238,7 +238,7 @@ pub fn get_url(args: &Args) -> String { let is_localhost = args.host.starts_with("localhost"); let protocol = if is_localhost { "http" } else { "https" }; let output_format = if !args.format.is_empty() && !args.extra.iter().any(|e| e.starts_with("format=")) { - let server_format = if args.format.eq_ignore_ascii_case("auto") || args.format.eq_ignore_ascii_case("expanded") { + let server_format = if args.format.eq_ignore_ascii_case("auto") || args.format.eq_ignore_ascii_case("vertical") { "JSONLines_Compact" } else { &args.format diff --git a/src/query.rs b/src/query.rs index 8026504..5eb1890 100644 --- a/src/query.rs +++ b/src/query.rs @@ -214,18 +214,18 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< .map(|(terminal_size::Width(w), _)| w) .unwrap_or(80); - let table_output = if context.args.is_expanded_mode() { - // Explicit expanded mode - stricter truncation (1000 chars) - table_renderer::render_table_expanded(&parsed.columns, &parsed.rows, terminal_width, 1000) + let table_output = if context.args.is_vertical_mode() { + // Explicit vertical mode - use vertical format with strict truncation + table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, 1000) } else { // Auto mode - intelligently choose display mode // Use 10000 char limit for detection (same as horizontal rendering) - if table_renderer::should_use_expanded_mode(&parsed.columns, &parsed.rows, terminal_width, 10000) { + if table_renderer::should_use_vertical_mode(&parsed.columns, &parsed.rows, terminal_width, 10000) { if context.args.verbose { - eprintln!("Note: Using expanded display mode (table too wide for horizontal display)"); + eprintln!("Note: Using vertical display mode (table too wide for horizontal display)"); } - // Auto-expanded mode - stricter truncation (1000 chars) - table_renderer::render_table_expanded(&parsed.columns, &parsed.rows, terminal_width, 1000) + // Auto-vertical mode - stricter truncation (1000 chars) + table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, 1000) } else { // Horizontal mode - generous truncation (10k chars) table_renderer::render_table(&parsed.columns, &parsed.rows, 10000) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index f020de1..7451494 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -164,215 +164,70 @@ fn display_width(s: &str) -> usize { width } -pub fn render_table_expanded(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> String { - const BORDER_OVERHEAD_PER_COL: usize = 3; // "│ " + " │" - const MIN_COL_WIDTH: usize = 5; // Absolute minimum to avoid squashing - +/// Render table in vertical format (two-column table with column names and values) +/// Used when table is too wide for horizontal display in auto mode +pub fn render_table_vertical( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> String { let mut output = String::new(); - let available_width = terminal_width as usize; for (row_idx, row) in rows.iter().enumerate() { - // Add row number header - let row_header = format!("╔═══ Row {} ", row_idx + 1); - let header_len = row_header.len(); - output.push_str(&row_header); - output.push_str(&"═".repeat(terminal_width.saturating_sub(header_len as u16) as usize - 1)); - output.push_str("╗\n"); - - // Calculate needed width for each column (max of column name and value) - let mut col_widths: Vec = Vec::new(); + // Row header + output.push_str(&format!("Row {}:\n", row_idx + 1)); + + // Create a two-column table for this row + let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); + + // Set column constraints to allow wrapping + // First column (names): narrow, fixed + // Second column (values): wide, allows wrapping + let available_width = if terminal_width > 10 { terminal_width - 4 } else { 76 }; + table.set_constraints(vec![ + ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(30)), // Column names + ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(available_width.saturating_sub(30))), // Values + ]); + + // Add rows (no header - just column name | value pairs) for (col_idx, col) in columns.iter().enumerate() { - let col_name_width = col.name.len(); - let value_width = if col_idx < row.len() { - let value_str = format_value(&row[col_idx]); - let truncated = if value_str.len() > max_value_length { - max_value_length + if col_idx < row.len() { + let value = format_value(&row[col_idx]); + + // Truncate long values + let truncated_value = if value.len() > max_value_length { + format!("{}...", &value[..max_value_length]) } else { - value_str.len() + value }; - truncated - } else { - 0 - }; - // Use the larger of column name or value width, with a minimum - col_widths.push(col_name_width.max(value_width).max(MIN_COL_WIDTH)); - } - // Dynamically chunk columns based on actual width requirements - let mut column_chunks: Vec> = Vec::new(); - let mut current_chunk: Vec = Vec::new(); - let mut current_width: usize = 4; // Start with table border overhead + // Column name cell (cyan, bold) + let name_cell = Cell::new(&col.name) + .fg(Color::Cyan) + .add_attribute(Attribute::Bold); - for (col_idx, &width) in col_widths.iter().enumerate() { - let col_with_border = width + BORDER_OVERHEAD_PER_COL; + // Value cell (default color) + let value_cell = Cell::new(truncated_value); - // Check if adding this column would exceed available width - if !current_chunk.is_empty() && (current_width + col_with_border > available_width) { - // Start a new chunk - column_chunks.push(current_chunk); - current_chunk = vec![col_idx]; - current_width = 4 + col_with_border; - } else { - // Add to current chunk - current_chunk.push(col_idx); - current_width += col_with_border; + table.add_row(vec![name_cell, value_cell]); } } - // Don't forget the last chunk - if !current_chunk.is_empty() { - column_chunks.push(current_chunk); - } - - for (chunk_idx, col_indices) in column_chunks.iter().enumerate() { - // Reuse the widths we already calculated for chunking - let min_widths: Vec = col_indices.iter().map(|&idx| col_widths[idx]).collect(); - - // Calculate total content width needed - let total_content_width: usize = min_widths.iter().sum(); - let num_cols = col_indices.len(); - - // Total table width = borders (4) + columns content + column separators (3 per column) - let total_min_width = 4 + total_content_width + (num_cols * 3); - - // Calculate extra space to distribute (make table exactly terminal_width) - let target_total_width = available_width; - let extra_space = if total_min_width < target_total_width { - target_total_width - total_min_width - } else { - 0 - }; + output.push_str(&table.to_string()); - // Distribute extra space proportionally among columns - let extra_per_col = if num_cols > 0 { extra_space / num_cols } else { 0 }; - let remainder = if num_cols > 0 { extra_space % num_cols } else { 0 }; - - // Create a mini table for this chunk - let mut table = Table::new(); - table.set_width(terminal_width); - // Use Dynamic arrangement to ensure content wraps within the terminal width - table.set_content_arrangement(ContentArrangement::Dynamic); - - // Add header row for this chunk - let header_cells: Vec = col_indices - .iter() - .map(|&idx| Cell::new(&columns[idx].name).fg(Color::Cyan).add_attribute(Attribute::Bold)) - .collect(); - table.set_header(header_cells); - - // Add value row for this chunk - let value_cells: Vec = col_indices - .iter() - .map(|&idx| { - let value_str = if idx < row.len() { format_value(&row[idx]) } else { String::new() }; - // Truncate strings exceeding max_value_length - // Let comfy-table handle wrapping for reasonable lengths - let display_value = if value_str.len() > max_value_length { - format!("{}...", &value_str[..max_value_length.saturating_sub(3)]) - } else { - value_str - }; - Cell::new(display_value) - }) - .collect(); - table.add_row(value_cells); - - // Set column constraints to ensure content fits within terminal width - // For single-column chunks with very wide content, limit to a reasonable width - if col_indices.len() == 1 && min_widths[0] > available_width / 2 { - // Single column with very wide content - set max width to allow wrapping - if let Some(column) = table.column_mut(0) { - // Use most of available width for the content column - let max_col_width = available_width.saturating_sub(10); // Leave room for borders - column.set_constraint(ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(max_col_width as u16))); - } - } else { - // Multiple columns or narrow content - set minimum width to prevent wrapping - // but allow columns to be wider if needed for proper layout - for (i, &min_width) in min_widths.iter().enumerate() { - let extra = extra_per_col + if i < remainder { 1 } else { 0 }; - let target_width = min_width + extra; - if let Some(column) = table.column_mut(i) { - // Use LowerBoundary instead of fixed boundaries to prevent content wrapping - column.set_constraint(ColumnConstraint::LowerBoundary(ComfyWidth::Fixed(target_width as u16))); - } - } - } - - // Render this chunk - let table_str = table.to_string(); - let lines: Vec<&str> = table_str.lines().collect(); - let num_lines = lines.len(); - - // For the first chunk, skip the top border (we have our custom header) - // For subsequent chunks, include everything - let start_line = if chunk_idx == 0 { 1 } else { 0 }; - - // Check if this is the last chunk - let is_last_chunk = chunk_idx == column_chunks.len() - 1; - - for (line_idx, line) in lines.iter().enumerate() { - if line_idx >= start_line { - // Skip the bottom border for non-last chunks since the next chunk's top border provides separation - if !is_last_chunk && line_idx == num_lines - 1 { - continue; - } - - // Swap border characters for better visual hierarchy: - // - Use '-' for header separator (lighter, less prominent) - // - Use '=' for chunk separator at top of non-first chunks (heavier, more prominent) - let mut processed_line = line.to_string(); - if line.starts_with('+') && line.contains('=') { - // Header separator line: change = to - - processed_line = processed_line.replace('=', "-"); - } else if line.starts_with('+') && line.contains('-') && line_idx == 0 && chunk_idx > 0 { - // Top border of non-first chunks: change - to = to emphasize separation - processed_line = processed_line.replace('-', "="); - } - - // Pad the line to terminal width for alignment - // Use display_width to ignore ANSI escape codes - let line_len = display_width(&processed_line); - if line_len < available_width { - // For lines ending with '+', extend with appropriate border character - let padded_line = if processed_line.ends_with('+') { - // Determine the border character from the line - let pad_char = if processed_line.contains('═') { - '═' - } else if processed_line.contains('=') { - '=' - } else if processed_line.contains('-') { - '-' - } else { - '-' - }; - let padding = pad_char.to_string().repeat(available_width - line_len); - format!("{}{}", &processed_line[..processed_line.len() - 1], padding) + "+" - } else { - // For content lines, pad with spaces - format!("{}{}", processed_line, " ".repeat(available_width - line_len)) - }; - output.push_str(&padded_line); - } else { - output.push_str(&processed_line); - } - output.push('\n'); - } - } - } - - // Add spacing between result rows for better visual separation + // Blank line between rows (except after last row) if row_idx < rows.len() - 1 { output.push('\n'); output.push('\n'); - output.push('\n'); } } output } -pub fn should_use_expanded_mode(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> bool { +pub fn should_use_vertical_mode(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> bool { const MAX_HORIZONTAL_COLUMNS: usize = 15; const BORDER_OVERHEAD_PER_COL: usize = 3; const MIN_COL_WIDTH: usize = 5; @@ -386,7 +241,7 @@ pub fn should_use_expanded_mode(columns: &[ResultColumn], rows: &[Vec], t } // Rule 2: Content-aware check - calculate if all columns can fit horizontally - // Use the same logic as expanded mode to calculate actual widths needed + // Use the same logic as vertical mode to calculate actual widths needed if !rows.is_empty() { let row = &rows[0]; @@ -409,7 +264,7 @@ pub fn should_use_expanded_mode(columns: &[ResultColumn], rows: &[Vec], t total_width += needed_width + BORDER_OVERHEAD_PER_COL; } - // If all columns don't fit, use expanded mode + // If all columns don't fit, use vertical mode if total_width > available_width { return true; } @@ -514,7 +369,7 @@ mod tests { } #[test] - fn test_render_expanded_single_row() { + fn test_render_vertical_single_row() { let columns = vec![ ResultColumn { name: "id".to_string(), @@ -535,83 +390,82 @@ mod tests { Value::String("active".to_string()), ]]; - let output = render_table_expanded(&columns, &rows, 80, 1000); + let output = render_table_vertical(&columns, &rows, 80, 1000); // Check for row header - assert!(output.contains("╔═══ Row 1")); + assert!(output.contains("Row 1:")); - // Check for column headers (should appear before values) + // Check for table format (contains column names) assert!(output.contains("id")); assert!(output.contains("name")); assert!(output.contains("status")); // Check for values + assert!(output.contains("1")); assert!(output.contains("Alice")); assert!(output.contains("active")); - // Verify structure: headers should appear before values in the output - let id_pos = output.find("id").unwrap(); - let alice_pos = output.find("Alice").unwrap(); - assert!(id_pos < alice_pos, "Column names should appear before values"); + // Should have table borders + assert!(output.contains('+')); + assert!(output.contains('|')); } #[test] - fn test_render_expanded_multiple_rows() { + fn test_render_vertical_multiple_rows() { let columns = vec![ ResultColumn { name: "id".to_string(), column_type: "int".to_string(), }, ResultColumn { - name: "value".to_string(), + name: "name".to_string(), column_type: "text".to_string(), }, ]; let rows = vec![ - vec![Value::Number(1.into()), Value::String("A".to_string())], - vec![Value::Number(2.into()), Value::String("B".to_string())], + vec![Value::Number(1.into()), Value::String("Alice".to_string())], + vec![Value::Number(2.into()), Value::String("Bob".to_string())], ]; - let output = render_table_expanded(&columns, &rows, 80, 1000); - assert!(output.contains("╔═══ Row 1")); - assert!(output.contains("╔═══ Row 2")); + let output = render_table_vertical(&columns, &rows, 80, 1000); + + // Should have both row headers + assert!(output.contains("Row 1:")); + assert!(output.contains("Row 2:")); - // Each row should have its own header line - assert_eq!(output.matches("╔═══ Row").count(), 2); + // Should have both names + assert!(output.contains("Alice")); + assert!(output.contains("Bob")); + + // Should have blank line between rows (two consecutive newlines between tables) + let parts: Vec<&str> = output.split("Row 2:").collect(); + assert!(parts.len() == 2); + // Check there's spacing before "Row 2:" + assert!(parts[0].ends_with("\n\n") || parts[0].ends_with("\n+")); } #[test] - fn test_value_truncation() { + fn test_render_vertical_value_truncation() { let columns = vec![ ResultColumn { - name: "short".to_string(), - column_type: "text".to_string(), - }, - ResultColumn { - name: "very_long".to_string(), + name: "long_col".to_string(), column_type: "text".to_string(), }, ]; + let long_value = "a".repeat(2000); // 2000 characters + let rows = vec![vec![Value::String(long_value)]]; - // Create a very long string (>1000 chars) that should be truncated - let long_string = "a".repeat(1500); - let rows = vec![vec![Value::String("ok".to_string()), Value::String(long_string.clone())]]; - - let output = render_table_expanded(&columns, &rows, 80, 1000); + let output = render_table_vertical(&columns, &rows, 80, 1000); - // The very long string should be truncated with "..." + // Should be truncated to 1000 chars + "..." assert!(output.contains("...")); - assert!(!output.contains(&long_string)); // Full string should not appear - // But strings under 1000 chars should NOT be truncated (no "..." marker) - let medium_string = "b".repeat(200); - let rows2 = vec![vec![Value::String("ok".to_string()), Value::String(medium_string.clone())]]; - let output2 = render_table_expanded(&columns, &rows2, 80, 1000); - - // Medium string should not be truncated (no "..." marker) - // Note: comfy-table may wrap it, so we just check it wasn't truncated - assert!(!output2.contains("bbb...")); - assert!(output2.contains("ok")); // At least verify basic rendering works + // Value should not exceed max_value_length + let lines: Vec<&str> = output.lines().collect(); + for line in lines { + // Each line in the table shouldn't be excessively long + assert!(line.len() < 1100); // Some margin for table borders + } } #[test] @@ -627,7 +481,7 @@ mod tests { } #[test] - fn test_should_use_expanded_mode() { + fn test_should_use_vertical_mode() { let columns = vec![ ResultColumn { name: "col1".to_string(), @@ -641,9 +495,9 @@ mod tests { let rows = vec![vec![Value::Number(1.into()), Value::String("test".to_string())]]; // Wide terminal, few columns -> horizontal - assert!(!should_use_expanded_mode(&columns, &rows, 150, 10000)); + assert!(!should_use_vertical_mode(&columns, &rows, 150, 10000)); - // Many columns with longer names -> expanded + // Many columns with longer names -> vertical let many_columns: Vec = (0..20) .map(|i| ResultColumn { name: format!("column_name_{}", i), @@ -651,9 +505,9 @@ mod tests { }) .collect(); let many_cols_row = vec![vec![Value::Number(1.into()); 20]]; - assert!(should_use_expanded_mode(&many_columns, &many_cols_row, 150, 10000)); + assert!(should_use_vertical_mode(&many_columns, &many_cols_row, 150, 10000)); - // Narrow terminal with many columns -> expanded + // Narrow terminal with many columns -> vertical let five_columns = vec![ ResultColumn { name: "a".to_string(), @@ -677,7 +531,7 @@ mod tests { }, ]; let five_cols_row = vec![vec![Value::Number(1.into()); 5]]; - assert!(should_use_expanded_mode(&five_columns, &five_cols_row, 40, 10000)); + assert!(should_use_vertical_mode(&five_columns, &five_cols_row, 40, 10000)); } #[test] @@ -702,8 +556,8 @@ mod tests { Value::String("and yet another long value".to_string()), ]]; - // Should detect that columns won't fit and use expanded mode - assert!(should_use_expanded_mode(&columns, &rows, 80, 10000)); + // Should detect that columns won't fit and use vertical mode + assert!(should_use_vertical_mode(&columns, &rows, 80, 10000)); } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index 7916bc3..9dfdb70 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -300,9 +300,9 @@ fn test_auto_format() { #[test] fn test_expanded_format() { - let (success, stdout, _) = run_fb(&["--core", "--format=expanded", "SELECT 1 as id, 'test' as name"]); + let (success, stdout, _) = run_fb(&["--core", "--format=vertical", "SELECT 1 as id, 'test' as name"]); assert!(success); - assert!(stdout.contains("╔═══ Row 1")); + assert!(stdout.contains("Row 1:")); assert!(stdout.contains("id")); assert!(stdout.contains("name")); assert!(stdout.contains("test")); @@ -310,7 +310,7 @@ fn test_expanded_format() { #[test] fn test_wide_table_auto_expanded() { - // Query with many columns should automatically use expanded mode + // Query with many columns should automatically use vertical mode let (success, stdout, _) = run_fb(&[ "--core", "--format=auto", @@ -318,7 +318,7 @@ fn test_wide_table_auto_expanded() { 7 as g, 8 as h, 9 as i, 10 as j, 11 as k, 12 as l, 13 as m", ]); assert!(success); - assert!(stdout.contains("╔═══ Row 1")); // Should auto-switch to expanded + assert!(stdout.contains("Row 1:")); // Should auto-switch to vertical } #[test] @@ -326,6 +326,6 @@ fn test_narrow_table_stays_horizontal() { // Query with few columns should stay horizontal let (success, stdout, _) = run_fb(&["--core", "--format=auto", "SELECT 1 as id, 'test' as name"]); assert!(success); - assert!(!stdout.contains("╔═══ Row 1")); // Should NOT use expanded + assert!(!stdout.contains("Row 1:")); // Should NOT use vertical assert!(stdout.contains("id")); // But still contains data } From 62a705f79c306127ddb3e889983d82609c45a027 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 15:43:01 +0100 Subject: [PATCH 009/147] Simplify vertical mode logic with configurable column-based switching Replaces content-aware vertical mode detection with simple math based on available space per column. Adds two configurable parameters for control. Changes: - Add --min-col-width (default: 15) to control vertical mode threshold - Add --max-cell-length (default: 1000) for content truncation - Replace should_use_vertical_mode with simple calculation: terminal_width / num_columns < min_col_width - Update horizontal table renderer to use equal column widths - Set explicit ColumnConstraint for predictable layout - Remove all content inspection from decision logic Benefits: - Predictable behavior independent of content - User-configurable thresholds via command-line options - Simpler code with no content-aware logic - Equal column widths for consistent visual alignment Co-Authored-By: Claude Sonnet 4.5 --- src/args.rs | 30 +++++++++ src/query.rs | 17 +++-- src/table_renderer.rs | 143 +++++++++++++++++++++--------------------- 3 files changed, 109 insertions(+), 81 deletions(-) diff --git a/src/args.rs b/src/args.rs index fbbff48..897cf39 100644 --- a/src/args.rs +++ b/src/args.rs @@ -20,6 +20,15 @@ impl Or for String { } } +// Default value functions for serde +fn default_min_col_width() -> usize { + 15 +} + +fn default_max_cell_length() -> usize { + 1000 +} + #[derive(Clone, Debug, Options, Deserialize, Serialize)] pub struct Args { #[options(help = "Run a single command and exit")] @@ -91,6 +100,14 @@ pub struct Args { #[serde(default)] pub no_spinner: bool, + #[options(no_short, help = "Minimum characters per column before switching to vertical mode", default = "15")] + #[serde(default = "default_min_col_width")] + pub min_col_width: usize, + + #[options(no_short, help = "Maximum cell content length before truncation", default = "1000")] + #[serde(default = "default_max_cell_length")] + pub max_cell_length: usize, + #[options(no_short, help = "Update default configuration values")] #[serde(skip_serializing, skip_deserializing)] pub update_defaults: bool, @@ -197,6 +214,19 @@ pub fn get_args() -> Result> { args.concise = args.concise || defaults.concise; args.hide_pii = args.hide_pii || defaults.hide_pii; + // Use defaults for numeric settings if not specified + if args.min_col_width == default_min_col_width() { + args.min_col_width = defaults.min_col_width; + } + if args.max_cell_length == default_max_cell_length() { + args.max_cell_length = defaults.max_cell_length; + } + + args.database = args + .database + .or(args.core.then(|| String::from("firebolt")).unwrap_or(defaults.database)) + .or(String::from("local_dev_db")); + if args.core { args.host = args.host.or(String::from("localhost:3473")); args.jwt = String::from(""); diff --git a/src/query.rs b/src/query.rs index 5eb1890..4d9ba86 100644 --- a/src/query.rs +++ b/src/query.rs @@ -215,20 +215,19 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< .unwrap_or(80); let table_output = if context.args.is_vertical_mode() { - // Explicit vertical mode - use vertical format with strict truncation - table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, 1000) + // Explicit vertical mode - use vertical format + table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, context.args.max_cell_length) } else { - // Auto mode - intelligently choose display mode - // Use 10000 char limit for detection (same as horizontal rendering) - if table_renderer::should_use_vertical_mode(&parsed.columns, &parsed.rows, terminal_width, 10000) { + // Auto mode - intelligently choose display mode based on columns and width + if table_renderer::should_use_vertical_mode(&parsed.columns, terminal_width, context.args.min_col_width) { if context.args.verbose { eprintln!("Note: Using vertical display mode (table too wide for horizontal display)"); } - // Auto-vertical mode - stricter truncation (1000 chars) - table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, 1000) + // Use vertical mode + table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, context.args.max_cell_length) } else { - // Horizontal mode - generous truncation (10k chars) - table_renderer::render_table(&parsed.columns, &parsed.rows, 10000) + // Use horizontal mode + table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) } }; diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 7451494..b3001bc 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -85,9 +85,25 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec], max_value_len // Enable dynamic content arrangement for automatic wrapping table.set_content_arrangement(ContentArrangement::Dynamic); - // Detect and set terminal width if available - if let Some((Width(w), _)) = terminal_size() { - table.set_width(w); + // Detect terminal width and calculate equal column widths + let terminal_width = terminal_size() + .map(|(Width(w), _)| w) + .unwrap_or(80); + + table.set_width(terminal_width); + + // Calculate equal column width if we have columns + let num_columns = columns.len(); + if num_columns > 0 { + // Subtract 4 for outer table borders, then divide equally + let available_width = terminal_width.saturating_sub(4); + let col_width = available_width / num_columns as u16; + + // Set explicit column constraints for equal widths + let constraints: Vec = (0..num_columns) + .map(|_| ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(col_width))) + .collect(); + table.set_constraints(constraints); } // Add headers with styling @@ -227,50 +243,16 @@ pub fn render_table_vertical( output } -pub fn should_use_vertical_mode(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> bool { - const MAX_HORIZONTAL_COLUMNS: usize = 15; - const BORDER_OVERHEAD_PER_COL: usize = 3; - const MIN_COL_WIDTH: usize = 5; - +pub fn should_use_vertical_mode(columns: &[ResultColumn], terminal_width: u16, min_col_width: usize) -> bool { let num_columns = columns.len(); - let available_width = terminal_width as usize; - - // Rule 1: Too many columns (more than 15 is definitely too many for horizontal) - if num_columns > MAX_HORIZONTAL_COLUMNS { - return true; - } - - // Rule 2: Content-aware check - calculate if all columns can fit horizontally - // Use the same logic as vertical mode to calculate actual widths needed - if !rows.is_empty() { - let row = &rows[0]; - - // Calculate needed width for each column (applying same truncation as rendering) - let mut total_width = 4; // Table borders - for (col_idx, col) in columns.iter().enumerate() { - let col_name_width = col.name.len(); - let value_width = if col_idx < row.len() { - let value_str = format_value(&row[col_idx]); - // Apply the same truncation logic as rendering to be consistent - if value_str.len() > max_value_length { - max_value_length - } else { - value_str.len() - } - } else { - 0 - }; - let needed_width = col_name_width.max(value_width).max(MIN_COL_WIDTH); - total_width += needed_width + BORDER_OVERHEAD_PER_COL; - } - // If all columns don't fit, use vertical mode - if total_width > available_width { - return true; - } + if num_columns == 0 { + return false; } - false + // Simple logic: switch to vertical if each column has less than min_col_width chars available + let chars_per_column = (terminal_width as usize) / num_columns; + chars_per_column < min_col_width } /// Format a Value for CSV output @@ -492,22 +474,23 @@ mod tests { column_type: "text".to_string(), }, ]; - let rows = vec![vec![Value::Number(1.into()), Value::String("test".to_string())]]; // Wide terminal, few columns -> horizontal - assert!(!should_use_vertical_mode(&columns, &rows, 150, 10000)); + // 150 width / 2 columns = 75 chars per column >= 10, stay horizontal + assert!(!should_use_vertical_mode(&columns, 150, 10)); - // Many columns with longer names -> vertical + // Many columns -> vertical + // 150 width / 20 columns = 7.5 chars per column < 10, use vertical let many_columns: Vec = (0..20) .map(|i| ResultColumn { name: format!("column_name_{}", i), column_type: "int".to_string(), }) .collect(); - let many_cols_row = vec![vec![Value::Number(1.into()); 20]]; - assert!(should_use_vertical_mode(&many_columns, &many_cols_row, 150, 10000)); + assert!(should_use_vertical_mode(&many_columns, 150, 10)); - // Narrow terminal with many columns -> vertical + // Narrow terminal with few columns -> vertical + // 40 width / 5 columns = 8 chars per column < 10, use vertical let five_columns = vec![ ResultColumn { name: "a".to_string(), @@ -530,34 +513,50 @@ mod tests { column_type: "int".to_string(), }, ]; - let five_cols_row = vec![vec![Value::Number(1.into()); 5]]; - assert!(should_use_vertical_mode(&five_columns, &five_cols_row, 40, 10000)); + assert!(should_use_vertical_mode(&five_columns, 40, 10)); + + // Configurable threshold test + // 80 width / 10 columns = 8 chars per column + let ten_columns: Vec = (0..10) + .map(|i| ResultColumn { + name: format!("col{}", i), + column_type: "int".to_string(), + }) + .collect(); + // With threshold 8, should stay horizontal (8 >= 8) + assert!(!should_use_vertical_mode(&ten_columns, 80, 8)); + // With threshold 9, should switch to vertical (8 < 9) + assert!(should_use_vertical_mode(&ten_columns, 80, 9)); } #[test] - fn test_content_too_wide_detection() { - let columns = vec![ - ResultColumn { - name: "column_one_with_long_name".to_string(), - column_type: "text".to_string(), - }, - ResultColumn { - name: "column_two_with_long_name".to_string(), - column_type: "text".to_string(), - }, - ResultColumn { - name: "column_three_also_long".to_string(), + fn test_vertical_mode_threshold() { + // Test that the decision is based purely on terminal_width / num_columns + let three_columns: Vec = (0..3) + .map(|i| ResultColumn { + name: format!("col{}", i), column_type: "text".to_string(), - }, - ]; - let rows = vec![vec![ - Value::String("this is a fairly long value".to_string()), - Value::String("another long value here".to_string()), - Value::String("and yet another long value".to_string()), - ]]; + }) + .collect(); + + // 80 width / 3 columns = 26.6 chars per column >= 10, stay horizontal + assert!(!should_use_vertical_mode(&three_columns, 80, 10)); - // Should detect that columns won't fit and use vertical mode - assert!(should_use_vertical_mode(&columns, &rows, 80, 10000)); + // But with a higher threshold of 30, should switch to vertical (26.6 < 30) + assert!(should_use_vertical_mode(&three_columns, 80, 30)); + + // Edge case: exactly at threshold + let eight_columns: Vec = (0..8) + .map(|i| ResultColumn { + name: format!("c{}", i), + column_type: "int".to_string(), + }) + .collect(); + // 80 width / 8 columns = 10 chars per column + // Should stay horizontal (10 >= 10) + assert!(!should_use_vertical_mode(&eight_columns, 80, 10)); + // Should switch to vertical (10 < 11) + assert!(should_use_vertical_mode(&eight_columns, 80, 11)); } #[test] From 98ad3c03e9eff1e59057d89378c61faa914c86f9 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 16:40:40 +0100 Subject: [PATCH 010/147] Fix empty string arguments in query.rs Replace eprintln!("") and println!("") with eprintln!() and println!() to fix clippy warnings about unnecessary empty string arguments. Co-Authored-By: Claude Sonnet 4.5 --- src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/query.rs b/src/query.rs index 4d9ba86..5f04c9c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -237,7 +237,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if !context.args.concise && parsed.statistics.is_some() { if let Some(stats) = parsed.statistics.as_ref() { if let Some(obj) = stats.as_object() { - eprintln!(""); // Empty line before stats + eprintln!(); // Empty line before stats for (key, value) in obj { eprintln!("{}: {}", key, value); } @@ -279,7 +279,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if let Some(request_id) = maybe_request_id { eprintln!("Request Id: {request_id}"); } - eprintln!("") + eprintln!() } } }; From 010aa4419f73c49a4143dfaea9559768d20577fa Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 16:40:55 +0100 Subject: [PATCH 011/147] Document intentionally unused protocol fields Add #[allow(dead_code)] attributes with explanatory comments to: - JsonLineMessage::Start fields (query_id, request_id, query_label) - ResultColumn.column_type field These fields are part of the Firebolt JSONLines_Compact protocol and required for deserialization but not currently used by the renderer. Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index b3001bc..5a5362a 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -9,8 +9,13 @@ pub enum JsonLineMessage { #[serde(rename = "START")] Start { result_columns: Vec, + // The following fields are part of the Firebolt JSONLines_Compact protocol + // and required for deserialization, but not used by the client renderer. + #[allow(dead_code)] query_id: String, + #[allow(dead_code)] request_id: String, + #[allow(dead_code)] query_label: Option, }, #[serde(rename = "DATA")] @@ -24,6 +29,10 @@ pub enum JsonLineMessage { #[derive(Clone, Debug, Deserialize)] pub struct ResultColumn { pub name: String, + // Column type from Firebolt server (e.g., "bigint", "integer", "text"). + // Currently unused for rendering but available for future type-aware formatting. + // Required for deserialization. + #[allow(dead_code)] #[serde(rename = "type")] pub column_type: String, } From d8b39db1335f5e0e65f972c00f06fc2824b5c6f9 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 16:41:50 +0100 Subject: [PATCH 012/147] Add comprehensive tests for Firebolt data type serialization Add 11 new test functions validating Firebolt's JSONLines_Compact serialization format for all data types: - BIGINT (JSON string for precision) - NUMERIC/DECIMAL (JSON string for exact decimals) - INT (JSON number) - DOUBLE/REAL (JSON number) - DATE/TIMESTAMP (ISO format strings) - ARRAY (JSON array) - TEXT (JSON string with unicode) - BYTEA (hex-encoded string) - GEOGRAPHY (WKB hex string) - BOOLEAN/NULL - CSV null handling differences These tests verify that format_value() correctly handles Firebolt's type-specific serialization patterns without requiring type-aware logic. Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 139 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 5a5362a..64d8401 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -349,6 +349,145 @@ mod tests { assert_eq!(result.errors.unwrap()[0].description, "Syntax error"); } + #[test] + fn test_format_value_firebolt_bigint() { + // BIGINT arrives as JSON string (not number) to preserve precision + let bigint_max = Value::String("9223372036854775807".to_string()); + assert_eq!(format_value(&bigint_max), "9223372036854775807"); + + let bigint_min = Value::String("-9223372036854775808".to_string()); + assert_eq!(format_value(&bigint_min), "-9223372036854775808"); + + // Should display as-is without quotes + let output = format_value(&bigint_max); + assert!(!output.starts_with('"'), "BIGINT should not have quotes in display"); + } + + #[test] + fn test_format_value_firebolt_numeric() { + // NUMERIC/DECIMAL arrives as JSON string for exact precision + let decimal = Value::String("1.23".to_string()); + assert_eq!(format_value(&decimal), "1.23"); + + let large_decimal = Value::String("12345678901234567890.123456789".to_string()); + assert_eq!(format_value(&large_decimal), "12345678901234567890.123456789"); + } + + #[test] + fn test_format_value_firebolt_integers() { + // INT arrives as JSON number + let int_val = Value::Number(42.into()); + assert_eq!(format_value(&int_val), "42"); + + let negative = Value::Number((-42).into()); + assert_eq!(format_value(&negative), "-42"); + + // INT min/max values + let int_max = Value::Number(2147483647.into()); + assert_eq!(format_value(&int_max), "2147483647"); + + let int_min = Value::Number((-2147483648).into()); + assert_eq!(format_value(&int_min), "-2147483648"); + } + + #[test] + fn test_format_value_firebolt_floats() { + // DOUBLE/REAL arrive as JSON numbers + let pi = serde_json::Number::from_f64(3.14159).unwrap(); + let formatted = format_value(&Value::Number(pi)); + assert!(formatted.starts_with("3.14"), "Got: {}", formatted); + + // Integer-valued float keeps decimal point + let one = serde_json::Number::from_f64(1.0).unwrap(); + assert_eq!(format_value(&Value::Number(one)), "1.0"); + } + + #[test] + fn test_format_value_firebolt_temporal() { + // DATE arrives as ISO format string + let date = Value::String("2026-02-06".to_string()); + assert_eq!(format_value(&date), "2026-02-06"); + + // TIMESTAMP arrives as ISO-like format with timezone + let timestamp = Value::String("2026-02-06 15:35:34.519403+00".to_string()); + assert_eq!(format_value(×tamp), "2026-02-06 15:35:34.519403+00"); + } + + #[test] + fn test_format_value_firebolt_arrays() { + // ARRAY arrives as JSON array + let int_array = serde_json::json!([1, 2, 3]); + let formatted = format_value(&int_array); + assert!(formatted.contains("1")); + assert!(formatted.contains("2")); + assert!(formatted.contains("3")); + + // Empty array + let empty = Value::Array(vec![]); + assert_eq!(format_value(&empty), "[]"); + } + + #[test] + fn test_format_value_firebolt_text() { + // Regular TEXT + let text = Value::String("regular text".to_string()); + assert_eq!(format_value(&text), "regular text"); + + // TEXT with JSON-like content (still a string, not parsed) + let json_text = Value::String("{\"key\": \"value\"}".to_string()); + assert_eq!(format_value(&json_text), "{\"key\": \"value\"}"); + + // Unicode text + let unicode = Value::String("🔥 emoji text".to_string()); + assert_eq!(format_value(&unicode), "🔥 emoji text"); + } + + #[test] + fn test_format_value_firebolt_bytea() { + // BYTEA arrives as hex-encoded string + let bytea = Value::String("\\x48656c6c6f".to_string()); + assert_eq!(format_value(&bytea), "\\x48656c6c6f"); + + // Should display as-is without interpretation + let output = format_value(&bytea); + assert!(output.starts_with("\\x"), "BYTEA should preserve hex encoding"); + } + + #[test] + fn test_format_value_firebolt_geography() { + // GEOGRAPHY arrives as WKB (Well-Known Binary) format in hex + let geo_point = Value::String("0101000020E6100000FEFFFFFFFFFFEF3F0000000000000040".to_string()); + assert_eq!(format_value(&geo_point), "0101000020E6100000FEFFFFFFFFFFEF3F0000000000000040"); + + // Should display as hex string without interpretation + let output = format_value(&geo_point); + assert!(output.len() > 20, "GEOGRAPHY hex strings are long"); + assert!(!output.starts_with('"'), "Should not have quotes in display"); + } + + #[test] + fn test_format_value_firebolt_null_and_bool() { + // NULL rendered as "NULL" string for clarity + assert_eq!(format_value(&Value::Null), "NULL"); + + // BOOLEAN rendered as lowercase + assert_eq!(format_value(&Value::Bool(true)), "true"); + assert_eq!(format_value(&Value::Bool(false)), "false"); + } + + #[test] + fn test_format_value_csv_null_handling() { + // In CSV, NULL should be empty string (not "NULL") + assert_eq!(format_value_csv(&Value::Null), ""); + + // But in table format, NULL should be "NULL" + assert_eq!(format_value(&Value::Null), "NULL"); + + // BIGINT strings should work in CSV too + let bigint = Value::String("9223372036854775807".to_string()); + assert_eq!(format_value_csv(&bigint), "9223372036854775807"); + } + #[test] fn test_format_value_null() { assert_eq!(format_value(&Value::Null), "NULL"); From 5ee78cd78d6c2938d302d961a021379dfda40bc2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 16:42:29 +0100 Subject: [PATCH 013/147] Remove unused display_width function Delete the display_width() function and its test test_display_width() as they were added but never used. The comfy_table library handles ANSI escape sequence width calculation internally with ContentArrangement::Dynamic. Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 64d8401..56b98f8 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -165,30 +165,6 @@ fn format_value(value: &Value) -> String { } /// Calculate the display width of a string, ignoring ANSI escape codes -fn display_width(s: &str) -> usize { - let mut width = 0; - let mut in_escape = false; - - for ch in s.chars() { - if ch == '\x1b' { - // Start of ANSI escape sequence - in_escape = true; - } else if in_escape { - // Inside escape sequence - check for end - if ch.is_ascii_alphabetic() { - // End of escape sequence (SGR codes end with a letter) - in_escape = false; - } - // Don't count characters inside escape sequences - } else { - // Regular character - count it - width += 1; - } - } - - width -} - /// Render table in vertical format (two-column table with column names and values) /// Used when table is too wide for horizontal display in auto mode pub fn render_table_vertical( @@ -707,24 +683,6 @@ mod tests { assert!(should_use_vertical_mode(&eight_columns, 80, 11)); } - #[test] - fn test_display_width() { - // Plain text - assert_eq!(display_width("hello"), 5); - - // Text with ANSI color codes (cyan) - assert_eq!(display_width("\x1b[36mhello\x1b[0m"), 5); - - // Text with ANSI bold + color - assert_eq!(display_width("\x1b[1m\x1b[36mhello\x1b[0m"), 5); - - // Empty string - assert_eq!(display_width(""), 0); - - // Only ANSI codes - assert_eq!(display_width("\x1b[36m\x1b[1m\x1b[0m"), 0); - } - #[test] fn test_write_result_as_csv() { let columns = vec![ From dccd963157be54fd9430bf4b6ce7dab6264f5d3c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 16:43:35 +0100 Subject: [PATCH 014/147] Document Firebolt JSONLines_Compact data type handling Add comprehensive documentation for Firebolt's type-specific JSON serialization patterns: 1. Added detailed function documentation for format_value() and format_value_csv() explaining how each Firebolt type maps to JSON 2. Added new "Data Type Handling in JSONLines_Compact Format" section to CLAUDE.md with: - Complete type mapping table (INT, BIGINT, NUMERIC, DATE, etc.) - Explanation of why BIGINT/NUMERIC use strings (precision) - How BYTEA and GEOGRAPHY are hex-encoded - Client-side rendering behavior - Note that column_type field is available for future use This documents the actual Firebolt serialization behavior validated by the comprehensive test suite added in previous commits. Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 41 +++++++++++++++++++++++++++++++++++++++++ src/table_renderer.rs | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9e9245..cc9349f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,47 @@ cargo clippy - Headers: `user-agent`, `Firebolt-Protocol-Version: 2.3`, `authorization` (Bearer token) - Handles special response headers: `firebolt-update-parameters`, `firebolt-remove-parameters`, `firebolt-update-endpoint` +### Data Type Handling in JSONLines_Compact Format + +**Firebolt Serialization Patterns:** + +The `JSONLines_Compact` format uses type-specific JSON serialization to preserve precision and avoid limitations of JSON numbers: + +| Firebolt Type | JSON Representation | Example | +|---------------|---------------------|---------| +| INT | JSON number | `42` | +| BIGINT | JSON **string** | `"9223372036854775807"` | +| NUMERIC/DECIMAL | JSON **string** | `"1.23"` | +| DOUBLE/REAL | JSON number | `3.14` | +| TEXT | JSON string | `"text"` | +| DATE | JSON string (ISO) | `"2026-02-06"` | +| TIMESTAMP | JSON string | `"2026-02-06 15:35:34+00"` | +| BYTEA | JSON string (hex) | `"\\x48656c6c6f"` | +| GEOGRAPHY | JSON string (WKB hex) | `"0101000020E6..."` | +| ARRAY | JSON array | `[1,2,3]` | +| BOOLEAN | JSON boolean | `true` | +| NULL | JSON `null` | `null` | + +**Why certain types are JSON strings:** +- **BIGINT/NUMERIC**: JavaScript/JSON numbers are 64-bit floats with ~53 bits precision. BIGINT (64-bit int) and NUMERIC (38 digits) need strings to avoid precision loss. +- **DATE/TIMESTAMP**: ISO format strings are portable and timezone-aware +- **BYTEA**: Binary data encoded as hex strings (e.g., `\x48656c6c6f` = "Hello") +- **GEOGRAPHY**: Spatial data in WKB (Well-Known Binary) format encoded as hex strings + +**Client-Side Rendering:** + +The `format_value()` function renders these types for display: +- Numbers displayed via `.to_string()` (e.g., `42`, `3.14`) +- Strings displayed as-is without quotes (BIGINT `"9223372036854775807"` → displays as `9223372036854775807`) +- NULL rendered as SQL-style `"NULL"` string in tables, empty string in CSV +- Arrays/Objects JSON-serialized with truncation at 1000 characters + +The `column_type` field from the server is currently unused but available for future type-aware formatting. + +**Server-Side Formats:** + +Formats like `PSQL`, `TabSeparatedWithNames`, etc. bypass client parsing entirely and are printed raw to stdout. + ### URL Construction URLs are built from: diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 56b98f8..2d59fff 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -145,6 +145,31 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec], max_value_len table.to_string() } +/// Format a serde_json::Value for table display. +/// +/// Handles Firebolt's JSONLines_Compact serialization format where different +/// Firebolt types are serialized to JSON in specific ways: +/// +/// **JSON Numbers** (rendered via `.to_string()`): +/// - INT, DOUBLE, REAL → JSON numbers (e.g., `42`, `3.14`) +/// +/// **JSON Strings** (rendered as-is without quotes): +/// - BIGINT → JSON strings to preserve precision (e.g., `"9223372036854775807"`) +/// - NUMERIC/DECIMAL → JSON strings for exact decimals (e.g., `"1.23"`) +/// - TEXT → JSON strings (e.g., `"regular text"`) +/// - DATE → ISO format strings (e.g., `"2026-02-06"`) +/// - TIMESTAMP → ISO-like strings with timezone (e.g., `"2026-02-06 15:35:34+00"`) +/// - BYTEA → Hex-encoded binary data (e.g., `"\\x48656c6c6f"`) +/// - GEOGRAPHY → WKB (Well-Known Binary) format in hex (e.g., `"0101000020E6..."`) +/// +/// **Other JSON Types**: +/// - ARRAY → JSON arrays (e.g., `[1,2,3]`) +/// - BOOLEAN → JSON booleans (e.g., `true`, `false`) +/// - NULL → JSON `null`, rendered as "NULL" string for SQL-style display +/// +/// Note: The `column_type` field in ResultColumn is available but currently unused. +/// It could be leveraged for future type-aware formatting (e.g., right-align numbers, +/// format dates differently). fn format_value(value: &Value) -> String { match value { Value::Null => "NULL".to_string(), @@ -240,7 +265,13 @@ pub fn should_use_vertical_mode(columns: &[ResultColumn], terminal_width: u16, m chars_per_column < min_col_width } -/// Format a Value for CSV output +/// Format a serde_json::Value for CSV export. +/// +/// Similar to format_value(), but with CSV-specific differences: +/// - `Null` → empty string "" (CSV standard for null values) +/// - Other types formatted identically to format_value() +/// +/// Maintains Firebolt's serialization: BIGINT strings, NUMERIC strings, etc. fn format_value_csv(val: &Value) -> String { match val { Value::Null => String::new(), From f119d3e7bf0c9cdec1aca2a7046674437ac563d9 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 16:56:29 +0100 Subject: [PATCH 015/147] Fix extra newline in non-table format output Change println! to print! for raw body output to match upstream behavior. The server response already includes trailing newlines, so println! was adding an extra unwanted newline. Co-Authored-By: Claude Sonnet 4.5 --- src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/query.rs b/src/query.rs index 5f04c9c..899f5fd 100644 --- a/src/query.rs +++ b/src/query.rs @@ -251,12 +251,12 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if context.args.verbose { eprintln!("Failed to parse table format: {}", e); } - println!("{}", body); + print!("{}", body); } } } else { // Original behavior for other formats - println!("{}", body); + print!("{}", body); } if !status.is_success() { From aff073b6b686c9efab53932337c98273cbc62832 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 17:31:38 +0100 Subject: [PATCH 016/147] Add color distinction for SQL NULL values SQL NULL values are now rendered in dark gray color to distinguish them from the string "NULL". This improves readability by making it immediately clear which values are actual nulls vs string data. Changes: - NULL values displayed in Color::DarkGrey in both horizontal and vertical table rendering modes - Added tests to verify NULL rendering doesn't crash - Works in both render_table() and render_table_vertical() To verify: Run a query with NULL values in a terminal: fb --core --format=auto "SELECT NULL as n, 'NULL' as s" The real NULL will appear in darker gray while the string 'NULL' will display in normal color. Co-Authored-By: Claude Sonnet 4.5 --- src/table_renderer.rs | 69 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 2d59fff..95a7aec 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -135,7 +135,12 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec], max_value_len } else { value_str }; - Cell::new(display_value) + // Color NULL values differently to distinguish from string "NULL" + if val.is_null() { + Cell::new(display_value).fg(Color::DarkGrey) + } else { + Cell::new(display_value) + } }) .collect(); @@ -234,8 +239,12 @@ pub fn render_table_vertical( .fg(Color::Cyan) .add_attribute(Attribute::Bold); - // Value cell (default color) - let value_cell = Cell::new(truncated_value); + // Value cell - color NULL values differently to distinguish from string "NULL" + let value_cell = if row[col_idx].is_null() { + Cell::new(truncated_value).fg(Color::DarkGrey) + } else { + Cell::new(truncated_value) + }; table.add_row(vec![name_cell, value_cell]); } @@ -760,4 +769,58 @@ mod tests { assert!(csv_str.contains("\"has\"\"quote\"")); assert!(csv_str.contains("\"has\nnewline\"")); } + + #[test] + fn test_null_rendering() { + // Test that NULL values and string "NULL" are both rendered correctly + // (Color distinction can be verified manually; tests just ensure no crashes) + let columns = vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "name".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![ + vec![Value::Number(1.into()), Value::Null], // Real NULL + vec![Value::Number(2.into()), Value::String("NULL".to_string())], // String "NULL" + vec![Value::Number(3.into()), Value::String("test".to_string())], // Regular string + ]; + + // Should not crash and should contain NULL text + let output = render_table(&columns, &rows, 1000); + assert!(output.contains("NULL")); + assert!(output.contains("test")); + assert!(output.contains('1')); + assert!(output.contains('2')); + assert!(output.contains('3')); + } + + #[test] + fn test_null_rendering_vertical() { + // Test that NULL values are rendered in vertical mode without crashes + let columns = vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "value".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![ + vec![Value::Number(1.into()), Value::Null], + vec![Value::Number(2.into()), Value::String("NULL".to_string())], + ]; + + // Should not crash and should contain NULL text + let output = render_table_vertical(&columns, &rows, 80, 1000); + assert!(output.contains("NULL")); + assert!(output.contains("id")); + assert!(output.contains("value")); + } } From 336577797e71daf76b6185049f8ab0fb974343d7 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 18:48:04 +0100 Subject: [PATCH 017/147] Implement two-mode format system with client: prefix notation Add explicit client-side vs server-side rendering modes using prefix notation (client:auto, client:vertical, client:horizontal for client rendering; PSQL, JSON, CSV, etc. for server rendering). Interactive sessions default to client:auto for pretty tables, while non-interactive sessions default to PSQL for backward compatibility. Include helpful warnings when users accidentally omit the client: prefix, and clarify Ctrl+V+Enter behavior in documentation. Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 44 ++++++++++++++ src/args.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 36 +++++++++--- src/query.rs | 16 ++++-- src/viewer.rs | 20 +++++-- tests/cli.rs | 67 ++++++++++++++++++++-- 6 files changed, 306 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc9349f..4f82b03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,49 @@ URLs are built from: - Query label: from `--label` - Advanced mode: automatically added for non-localhost +### Output Format Options with Client Prefix + +fb-cli uses a single `--format` option with a prefix notation to distinguish between client-side and server-side rendering: + +**Client-Side Rendering** (prefix with `client:`): +- Values: `client:auto`, `client:vertical`, `client:horizontal` +- Behavior: Client requests `JSONLines_Compact` and renders it with formatting +- Supports: Pretty tables, NULL coloring, csvlens viewer integration +- Default in interactive mode: `client:auto` + +**Client display modes:** +- `client:auto` - Smart switching: horizontal for narrow tables, vertical for wide tables +- `client:horizontal` - Force horizontal table with column headers +- `client:vertical` - Force vertical two-column layout (column | value) + +**Server-Side Rendering** (no prefix): +- Values: `PSQL`, `TabSeparatedWithNames`, `JSON`, `CSV`, `JSONLines_Compact`, etc. +- Behavior: Server renders output in this format, client prints raw output +- Default in non-interactive mode: `PSQL` + +**Detection:** +- If `--format` starts with `client:`: Use client-side rendering +- Otherwise: Use server-side rendering + +**Default Behavior:** +- Interactive sessions (TTY): `--format client:auto` (client-side pretty tables) +- Non-interactive (piped/redirected): `--format PSQL` (server-side, backwards compatible) +- Can be overridden with explicit `--format` flag + +**Changing format at runtime:** +- Command-line: `--format client:auto` or `--format JSON` +- SQL-style: `set format = client:vertical;` or `set format = CSV;` +- Reset: `unset format;` (resets to default based on interactive mode) + +**Config persistence:** +- Saved in `~/.firebolt/fb_config` +- Format can be persisted with `--update-defaults` +- Clear which mode: `client:` prefix means client-side, no prefix means server-side + +**csvlens Integration:** +- Only works with client-side formats (`client:*`) +- Automatically checks and displays error if server-side format used + ### Firebolt Core vs Standard - **Core mode** (`--core`): Connects to Firebolt Core at `localhost:3473`, database `firebolt`, format `PSQL`, no JWT @@ -156,5 +199,6 @@ URLs are built from: - **REPL multi-line**: Press Ctrl+O to insert newline. Queries must end with semicolon. - **Ctrl+C in REPL**: Cancels current input but doesn't exit - **Ctrl+D in REPL**: Exits (EOF) +- **Ctrl+V in REPL**: Inserts `\view` command (press Enter to execute and open csvlens viewer for last result) - **Spinner**: Shown during query execution unless `--no-spinner` or `--concise` - **History**: Saved to `~/.firebolt/fb_history` (max 10,000 entries), supports Ctrl+R search diff --git a/src/args.rs b/src/args.rs index 897cf39..2d2affb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -2,6 +2,7 @@ use gumdrop::Options; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; +use std::io::IsTerminal; use crate::utils::{config_path, init_root_path}; @@ -47,7 +48,7 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub database: String, - #[options(help = "Output format (auto, vertical, PSQL, TabSeparatedWithNames, JSONLines_Compact, ...)")] + #[options(help = "Output format (client:auto, client:vertical, client:horizontal, PSQL, JSON, CSV, ...)")] #[serde(default)] pub format: String, @@ -127,11 +128,30 @@ pub struct Args { impl Args { pub fn should_render_table(&self) -> bool { - self.format.eq_ignore_ascii_case("auto") || self.format.eq_ignore_ascii_case("vertical") + // Client rendering when format starts with "client:" + self.format.starts_with("client:") } - pub fn is_vertical_mode(&self) -> bool { - self.format.eq_ignore_ascii_case("vertical") + /// Extract display mode from client: prefix + /// "client:auto" → "auto", "client:vertical" → "vertical", "PSQL" → "" + pub fn get_display_mode(&self) -> &str { + if self.format.starts_with("client:") { + &self.format[7..] // Skip "client:" prefix + } else { + "" + } + } + + pub fn is_vertical_display(&self) -> bool { + self.get_display_mode().eq_ignore_ascii_case("vertical") + } + + pub fn is_horizontal_display(&self) -> bool { + self.get_display_mode().eq_ignore_ascii_case("horizontal") + } + + pub fn is_auto_display(&self) -> bool { + self.get_display_mode().eq_ignore_ascii_case("auto") } } @@ -227,12 +247,28 @@ pub fn get_args() -> Result> { .or(args.core.then(|| String::from("firebolt")).unwrap_or(defaults.database)) .or(String::from("local_dev_db")); + // Detect if running in interactive mode + let is_interactive = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); + if args.core { args.host = args.host.or(String::from("localhost:3473")); args.jwt = String::from(""); args.format = args.format.or(String::from("PSQL")); } else { - args.format = args.format.or(defaults.format).or(String::from("PSQL")); + // Apply smart defaults based on mode if format is not already set + let default_format = if args.format.is_empty() && defaults.format.is_empty() { + if is_interactive { + // Interactive mode: default to client-side rendering with auto display + String::from("client:auto") + } else { + // Non-interactive mode: default to server-side rendering with PSQL + String::from("PSQL") + } + } else { + String::new() + }; + + args.format = args.format.or(defaults.format).or(default_format); args.host = args.host.or(defaults.host).or(default_host); } @@ -242,6 +278,16 @@ pub fn get_args() -> Result> { args.extra = normalize_extras(extras, false)?; } + // Warn if user specified a client format name without the "client:" prefix + if args.format.eq_ignore_ascii_case("auto") + || args.format.eq_ignore_ascii_case("vertical") + || args.format.eq_ignore_ascii_case("horizontal") { + eprintln!("Warning: Format '{}' is not supported by the server.", args.format); + eprintln!("Did you mean '--format client:{}'?", args.format.to_lowercase()); + eprintln!("Client-side formats require the 'client:' prefix (e.g., client:auto, client:vertical, client:horizontal)"); + eprintln!(); + } + Ok(args) } @@ -268,12 +314,13 @@ pub fn get_url(args: &Args) -> String { let is_localhost = args.host.starts_with("localhost"); let protocol = if is_localhost { "http" } else { "https" }; let output_format = if !args.format.is_empty() && !args.extra.iter().any(|e| e.starts_with("format=")) { - let server_format = if args.format.eq_ignore_ascii_case("auto") || args.format.eq_ignore_ascii_case("vertical") { - "JSONLines_Compact" + if args.format.starts_with("client:") { + // Client-side rendering: always use JSONLines_Compact + format!("&output_format=JSONLines_Compact") } else { - &args.format - }; - format!("&output_format={}", server_format) + // Server-side rendering: use format as-is + format!("&output_format={}", &args.format) + } } else { String::new() }; @@ -382,4 +429,92 @@ mod tests { assert_eq!(result[1], "param2=value%20with%20spaces"); assert_eq!(result[2], "param3=%20%20value%20with%20spaces%20"); } + + #[test] + fn test_should_render_table_with_client_prefix() { + let mut args = Args::parse_args_default_or_exit(); + + // Server-side format: should not render + args.format = String::from("PSQL"); + assert!(!args.should_render_table()); + + args.format = String::from("JSON"); + assert!(!args.should_render_table()); + + // Client-side format: should render + args.format = String::from("client:auto"); + assert!(args.should_render_table()); + + args.format = String::from("client:vertical"); + assert!(args.should_render_table()); + + args.format = String::from("client:horizontal"); + assert!(args.should_render_table()); + } + + #[test] + fn test_get_display_mode() { + let mut args = Args::parse_args_default_or_exit(); + + // Client formats + args.format = String::from("client:auto"); + assert_eq!(args.get_display_mode(), "auto"); + + args.format = String::from("client:vertical"); + assert_eq!(args.get_display_mode(), "vertical"); + + args.format = String::from("client:horizontal"); + assert_eq!(args.get_display_mode(), "horizontal"); + + // Server formats + args.format = String::from("PSQL"); + assert_eq!(args.get_display_mode(), ""); + + args.format = String::from("JSON"); + assert_eq!(args.get_display_mode(), ""); + } + + #[test] + fn test_display_mode_helpers() { + let mut args = Args::parse_args_default_or_exit(); + + args.format = String::from("client:auto"); + assert!(args.is_auto_display()); + assert!(!args.is_vertical_display()); + assert!(!args.is_horizontal_display()); + + args.format = String::from("client:vertical"); + assert!(!args.is_auto_display()); + assert!(args.is_vertical_display()); + assert!(!args.is_horizontal_display()); + + args.format = String::from("client:horizontal"); + assert!(!args.is_auto_display()); + assert!(!args.is_vertical_display()); + assert!(args.is_horizontal_display()); + + args.format = String::from("PSQL"); + assert!(!args.is_auto_display()); + assert!(!args.is_vertical_display()); + assert!(!args.is_horizontal_display()); + } + + #[test] + fn test_format_without_client_prefix() { + // Test that formats "auto", "vertical", "horizontal" without "client:" prefix + // are recognized (they will trigger a warning at runtime, but are valid format strings) + let mut args = Args::parse_args_default_or_exit(); + + args.format = String::from("auto"); + assert!(!args.should_render_table()); // Should NOT render because no "client:" prefix + assert_eq!(args.get_display_mode(), ""); // Empty because no prefix + + args.format = String::from("vertical"); + assert!(!args.should_render_table()); + assert_eq!(args.get_display_mode(), ""); + + args.format = String::from("horizontal"); + assert!(!args.should_render_table()); + assert_eq!(args.get_display_mode(), ""); + } } diff --git a/src/main.rs b/src/main.rs index f844a5d..e4b5aa8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,7 +69,7 @@ async fn main() -> Result<(), Box> { ); if is_tty && !context.args.concise { - eprintln!("Type \\help for available commands or press Ctrl+V to view last result. Ctrl+D to exit."); + eprintln!("Type \\help for available commands or press Ctrl+V then Enter to view last result. Ctrl+D to exit."); } let mut buffer: String = String::new(); let mut has_error = false; @@ -115,14 +115,36 @@ async fn main() -> Result<(), Box> { } else if trimmed == "\\help" { // Show help for special commands eprintln!("Special commands:"); - eprintln!(" \\view - Open last query result in interactive csvlens viewer"); - eprintln!(" \\help - Show this help message"); + eprintln!(" \\view - Open last query result in csvlens viewer"); + eprintln!(" (requires client format: client:auto, client:vertical, or client:horizontal)"); + eprintln!(" \\help - Show this help message"); + eprintln!(); + eprintln!("SQL-style commands:"); + eprintln!(" set format = ; - Change output format"); + eprintln!(" unset format; - Reset format to default"); + eprintln!(); + eprintln!("Format values:"); + eprintln!(" Client-side rendering (prefix with 'client:'):"); + eprintln!(" client:auto - Smart switching between horizontal/vertical (default in interactive)"); + eprintln!(" client:horizontal - Force horizontal table layout"); + eprintln!(" client:vertical - Force vertical two-column layout"); + eprintln!(); + eprintln!(" Server-side rendering (no prefix):"); + eprintln!(" PSQL - PostgreSQL-style format (default in non-interactive)"); + eprintln!(" JSON - JSON format"); + eprintln!(" CSV - CSV format"); + eprintln!(" TabSeparatedWithNames - TSV with headers"); + eprintln!(" JSONLines_Compact - JSON Lines format"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" set format = client:vertical; # Use client-side vertical display"); + eprintln!(" set format = JSON; # Use server-side JSON output"); eprintln!(); eprintln!("Keyboard shortcuts:"); - eprintln!(" Ctrl+V - Open last query result in csvlens viewer (same as \\view)"); - eprintln!(" Ctrl+O - Insert newline (for multi-line queries)"); - eprintln!(" Ctrl+D - Exit REPL"); - eprintln!(" Ctrl+C - Cancel current input"); + eprintln!(" Ctrl+V then Enter - Open last query result in csvlens viewer (inserts \\view)"); + eprintln!(" Ctrl+O - Insert newline (for multi-line queries)"); + eprintln!(" Ctrl+D - Exit REPL"); + eprintln!(" Ctrl+C - Cancel current input"); continue; } diff --git a/src/query.rs b/src/query.rs index 899f5fd..9519304 100644 --- a/src/query.rs +++ b/src/query.rs @@ -214,21 +214,25 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< .map(|(terminal_size::Width(w), _)| w) .unwrap_or(80); - let table_output = if context.args.is_vertical_mode() { - // Explicit vertical mode - use vertical format + let table_output = if context.args.is_horizontal_display() { + // Force horizontal table layout + table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) + } else if context.args.is_vertical_display() { + // Force vertical two-column layout table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, context.args.max_cell_length) - } else { - // Auto mode - intelligently choose display mode based on columns and width + } else if context.args.is_auto_display() { + // Auto mode - intelligently choose display mode if table_renderer::should_use_vertical_mode(&parsed.columns, terminal_width, context.args.min_col_width) { if context.args.verbose { eprintln!("Note: Using vertical display mode (table too wide for horizontal display)"); } - // Use vertical mode table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, context.args.max_cell_length) } else { - // Use horizontal mode table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) } + } else { + // Fallback to horizontal if format starts with client: but mode not recognized + table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) }; println!("{}", table_output); diff --git a/src/viewer.rs b/src/viewer.rs index 7deb377..83f95ee 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -5,6 +5,11 @@ use std::fs::File; /// Open csvlens viewer for the last query result pub fn open_csvlens_viewer(context: &Context) -> Result<(), Box> { + // csvlens only works with client-side rendering (when last_result is populated) + if !context.args.format.starts_with("client:") { + return Err("csvlens viewer requires client-side rendering. Use --format client:auto or similar.".into()); + } + // Check if we have a result to display let result = match &context.last_result { Some(r) => r, @@ -69,7 +74,8 @@ mod tests { #[test] fn test_no_result_error() { - let args = crate::args::get_args().unwrap(); + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); let context = Context::new(args); // Should not panic, should return Ok with error message @@ -79,7 +85,8 @@ mod tests { #[test] fn test_error_result() { - let args = crate::args::get_args().unwrap(); + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); let mut context = Context::new(args); context.last_result = Some(ParsedResult { columns: vec![], @@ -96,7 +103,8 @@ mod tests { #[test] fn test_empty_columns() { - let args = crate::args::get_args().unwrap(); + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); let mut context = Context::new(args); context.last_result = Some(ParsedResult { columns: vec![], @@ -111,7 +119,8 @@ mod tests { #[test] fn test_empty_rows() { - let args = crate::args::get_args().unwrap(); + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); let mut context = Context::new(args); context.last_result = Some(ParsedResult { columns: vec![ResultColumn { @@ -129,7 +138,8 @@ mod tests { #[test] fn test_csv_file_creation() { - let args = crate::args::get_args().unwrap(); + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); let mut context = Context::new(args); context.last_result = Some(ParsedResult { columns: vec![ diff --git a/tests/cli.rs b/tests/cli.rs index 9dfdb70..74efbaa 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -291,7 +291,7 @@ fn test_exit_code_on_query_error_interactive() { #[test] fn test_auto_format() { - let (success, stdout, _) = run_fb(&["--core", "--format=auto", "SELECT 1 as id, 'test' as name"]); + let (success, stdout, _) = run_fb(&["--core", "--format=client:auto", "SELECT 1 as id, 'test' as name"]); assert!(success); assert!(stdout.contains("id")); assert!(stdout.contains("name")); @@ -300,7 +300,7 @@ fn test_auto_format() { #[test] fn test_expanded_format() { - let (success, stdout, _) = run_fb(&["--core", "--format=vertical", "SELECT 1 as id, 'test' as name"]); + let (success, stdout, _) = run_fb(&["--core", "--format=client:vertical", "SELECT 1 as id, 'test' as name"]); assert!(success); assert!(stdout.contains("Row 1:")); assert!(stdout.contains("id")); @@ -313,7 +313,7 @@ fn test_wide_table_auto_expanded() { // Query with many columns should automatically use vertical mode let (success, stdout, _) = run_fb(&[ "--core", - "--format=auto", + "--format=client:auto", "SELECT 1 as a, 2 as b, 3 as c, 4 as d, 5 as e, 6 as f, \ 7 as g, 8 as h, 9 as i, 10 as j, 11 as k, 12 as l, 13 as m", ]); @@ -324,8 +324,67 @@ fn test_wide_table_auto_expanded() { #[test] fn test_narrow_table_stays_horizontal() { // Query with few columns should stay horizontal - let (success, stdout, _) = run_fb(&["--core", "--format=auto", "SELECT 1 as id, 'test' as name"]); + let (success, stdout, _) = run_fb(&["--core", "--format=client:auto", "SELECT 1 as id, 'test' as name"]); assert!(success); assert!(!stdout.contains("Row 1:")); // Should NOT use vertical assert!(stdout.contains("id")); // But still contains data } + +#[test] +fn test_client_format_horizontal() { + let (success, stdout, _) = run_fb(&["--core", "--format=client:horizontal", "SELECT 1 as id, 'test' as name"]); + assert!(success); + + // Should have horizontal table format + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); + assert!(stdout.contains("test")); + assert!(stdout.contains('+')); // Has borders + assert!(stdout.contains('|')); // Has column separators + + // Should NOT use vertical format + assert!(!stdout.contains("Row 1")); +} + +#[test] +fn test_client_format_vertical() { + let (success, stdout, _) = run_fb(&["--core", "--format=client:vertical", "SELECT 1 as id, 'test' as name"]); + assert!(success); + + // Should have vertical format + assert!(stdout.contains("Row 1")); + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); +} + +#[test] +fn test_client_format_auto() { + // Auto should choose based on terminal width + let (success, stdout, _) = run_fb(&["--core", "--format=client:auto", "SELECT 1 as id"]); + assert!(success); + + // Should have table format + assert!(stdout.contains('+')); // Has table borders + assert!(stdout.contains("id")); +} + +#[test] +fn test_server_format_json() { + // Server-side format (no client: prefix) + let (success, stdout, _) = run_fb(&["--core", "--format=JSON_Compact", "SELECT 1 as id"]); + assert!(success); + + // Should have JSON format from server + assert!(stdout.contains('{')); // JSON + assert!(!stdout.contains('+')); // Not a table +} + +#[test] +fn test_server_format_psql() { + let (success, stdout, _) = run_fb(&["--core", "--format=PSQL", "SELECT 1 as id"]); + assert!(success); + + // Should have PSQL format from server + assert!(!stdout.contains('+')); // No table borders (PSQL style is different) + assert!(stdout.contains("id")); +} From 01319d5d5c108d8496906bc38493d92e422e5642 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 19:03:55 +0100 Subject: [PATCH 018/147] Improve statistics display with concise formatting Replace verbose multi-line statistics with a single clean line showing only relevant metrics: row count with thousand separators and scanned bytes with smart KB/MB/GB formatting broken down by local (cache) and remote (storage). Statistics appear between Time and Request Id for better readability. Respects --concise flag to suppress all metadata. Co-Authored-By: Claude Sonnet 4.5 --- src/context.rs | 2 + src/query.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/context.rs b/src/context.rs index 76eda49..0f7239a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -18,6 +18,7 @@ pub struct Context { pub prompt2: Option, pub prompt3: Option, pub last_result: Option, + pub last_stats: Option, } impl Context { @@ -31,6 +32,7 @@ impl Context { prompt2: None, prompt3: None, last_result: None, + last_stats: None, } } diff --git a/src/query.rs b/src/query.rs index 9519304..6181f53 100644 --- a/src/query.rs +++ b/src/query.rs @@ -14,6 +14,46 @@ use crate::utils::spin; use crate::FIREBOLT_PROTOCOL_VERSION; use crate::USER_AGENT; +// Format bytes with appropriate unit (B, KB, MB, GB, TB) +fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + + if bytes == 0 { + return "0 B".to_string(); + } + + let bytes_f64 = bytes as f64; + let unit_index = (bytes_f64.log2() / 10.0).floor() as usize; + let unit_index = unit_index.min(UNITS.len() - 1); + + let value = bytes_f64 / (1024_f64.powi(unit_index as i32)); + + if value >= 100.0 { + format!("{:.0} {}", value, UNITS[unit_index]) + } else if value >= 10.0 { + format!("{:.1} {}", value, UNITS[unit_index]) + } else { + format!("{:.2} {}", value, UNITS[unit_index]) + } +} + +// Format number with thousand separators +fn format_number(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + let mut count = 0; + + for c in s.chars().rev() { + if count > 0 && count % 3 == 0 { + result.push(','); + } + result.push(c); + count += 1; + } + + result.chars().rev().collect() +} + // Set parameters via query pub fn set_args(context: &mut Context, query: &str) -> Result> { // set flag = value; @@ -237,17 +277,39 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< println!("{}", table_output); - // Show statistics (if not --concise) - if !context.args.concise && parsed.statistics.is_some() { - if let Some(stats) = parsed.statistics.as_ref() { - if let Some(obj) = stats.as_object() { - eprintln!(); // Empty line before stats - for (key, value) in obj { - eprintln!("{}: {}", key, value); + // Store statistics for display later (after Time) + context.last_stats = if !context.args.concise && parsed.statistics.is_some() { + parsed.statistics.as_ref().and_then(|stats| { + stats.as_object().map(|obj| { + let scanned_cache = obj.get("scanned_bytes_cache") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let scanned_storage = obj.get("scanned_bytes_storage") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let rows_read = obj.get("rows_read") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let total_scanned = scanned_cache + scanned_storage; + + // Format: "Scanned: x rows, y B (..B local, ..B remote)" + if rows_read > 0 || total_scanned > 0 { + Some(format!( + "Scanned: {} rows, {} ({} local, {} remote)", + format_number(rows_read), + format_bytes(total_scanned), + format_bytes(scanned_cache), + format_bytes(scanned_storage) + )) + } else { + None } - } - } - } + }).flatten() + }) + } else { + None + }; } } Err(e) => { @@ -280,6 +342,10 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if !context.args.concise { let elapsed = format!("{:?}", elapsed / 100000 * 100000); eprintln!("Time: {elapsed}"); + // Print statistics if available (from client-side rendering) + if let Some(stats) = &context.last_stats { + eprintln!("{}", stats); + } if let Some(request_id) = maybe_request_id { eprintln!("Request Id: {request_id}"); } @@ -791,6 +857,36 @@ mod tests { assert!(try_split_queries(input).is_none()); } + #[test] + fn test_format_bytes() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(1), "1.00 B"); + assert_eq!(format_bytes(100), "100 B"); + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1024), "1.00 KB"); + assert_eq!(format_bytes(1536), "1.50 KB"); + assert_eq!(format_bytes(10240), "10.0 KB"); + assert_eq!(format_bytes(102400), "100 KB"); + assert_eq!(format_bytes(1048576), "1.00 MB"); + assert_eq!(format_bytes(1572864), "1.50 MB"); + assert_eq!(format_bytes(10485760), "10.0 MB"); + assert_eq!(format_bytes(104857600), "100 MB"); + assert_eq!(format_bytes(1073741824), "1.00 GB"); + assert_eq!(format_bytes(1099511627776), "1.00 TB"); + } + + #[test] + fn test_format_number() { + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(1), "1"); + assert_eq!(format_number(999), "999"); + assert_eq!(format_number(1000), "1,000"); + assert_eq!(format_number(1234), "1,234"); + assert_eq!(format_number(123456), "123,456"); + assert_eq!(format_number(1234567), "1,234,567"); + assert_eq!(format_number(1234567890), "1,234,567,890"); + } + #[test] fn test_empty_strings() { // Raw strings From d53aa1ffc7f3a5474e33b93c8f487844aca3d7c9 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 19:10:04 +0100 Subject: [PATCH 019/147] Update README with new output format features Document client-side rendering with client: prefix notation, interactive result exploration via csvlens viewer, smart statistics formatting, and updated keyboard shortcuts. Include practical example using information_schema.engine_query_history. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c40fe5..74c334f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,52 @@ Time: 87.747ms Also support history + search in it (`CTRL+R`). +## Output Formats + +### Client-Side Rendering + +Use `--format client:auto` (default in interactive mode) for pretty table output with smart formatting: + +``` +=> select * from information_schema.engine_query_history limit 3; ++--------------------+-------------+--------+ +| query_id | query_label | status | ++===============================================+ +| abc123... | NULL | ENDED | +| def456... | my_query | ENDED | +| ghi789... | NULL | ENDED | ++--------------------+-------------+--------+ +Time: 15.2ms +Scanned: 3 rows, 1.5 KB (1.2 KB local, 300 B remote) +Request Id: xyz... +``` + +Available client modes: +- `client:auto` - Smart switching between horizontal/vertical layout +- `client:vertical` - Two-column vertical layout for wide tables +- `client:horizontal` - Standard horizontal table + +### Interactive Result Exploration + +Press `Ctrl+V` then `Enter` (or type `\view`) to open the last query result in an interactive viewer powered by [csvlens](https://github.com/YS-L/csvlens). **Note:** Requires client-side output formats (`client:auto`, `client:vertical`, or `client:horizontal`). + +``` +=> select * from information_schema.engine_query_history; +[... table output ...] + +=> \view +[Opens interactive csvlens viewer with sorting, filtering, and navigation] +``` + +### Server-Side Rendering + +Use format names without prefix for server-rendered output (default in non-interactive/piped mode): +- `PSQL` - PostgreSQL-style format +- `JSON` - JSON output +- `CSV` - CSV format +- `TabSeparatedWithNames` - TSV with headers +- And more... + ## Help ``` @@ -49,7 +95,7 @@ Optional arguments: -C, --core Preset of settings to connect to Firebolt Core -h, --host HOSTNAME Hostname (and port) to connect to -d, --database DATABASE Database name to use - -f, --format FORMAT Output format (e.g., TabSeparatedWithNames, PSQL, JSONLines_Compact, Vertical, ...) + -f, --format FORMAT Output format (client:auto, client:vertical, client:horizontal, TabSeparatedWithNames, PSQL, JSONLines_Compact, ...) -e, --extra EXTRA Extra settings in the form --extra = -l, --label LABEL Query label for tracking or identification -j, --jwt JWT JWT for authentication @@ -96,7 +142,8 @@ Most of them from https://github.com/kkawakam/rustyline: Some of them specific to `fb`: | Keystroke | Action | | --------------------- | --------------------------------------------------------------------------- | -| Ctrl-C | Cancel current input. | +| Ctrl-V then Enter | Open last query result in interactive csvlens viewer | +| Ctrl-C | Cancel current input | | Ctrl-O | Insert a newline | From f709e849de0952b4f8bda77c263b48f69c744772 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Feb 2026 19:26:17 +0100 Subject: [PATCH 020/147] Remove unused home dependency from Cargo.toml The home crate was accidentally added but is not used anywhere in the codebase. The project uses the dirs crate for home directory operations instead. Co-Authored-By: Claude Sonnet 4.5 --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3283219..ec3d17c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,5 +25,4 @@ pest = "2.7" pest_derive = "2.7" terminal_size = "0.3" comfy-table = "6.2" -home = "< 0.5.12" csvlens = "0.14" From a5a91513bbe164604269b3643701557944bcd4d6 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Thu, 19 Feb 2026 20:24:39 +0100 Subject: [PATCH 021/147] Limit interactive output to 10k rows / 1MB, with wider cells for single-column results - Apply row (10,000) and byte (1MB) limits only in interactive TTY mode; non-interactive output and csvlens viewer always receive the full result - Single-column results get 5x the normal max cell length (5,000 vs 1,000) - Track interactive mode via Context.is_interactive set from main Co-Authored-By: Claude Sonnet 4.6 --- src/args.rs | 2 ++ src/context.rs | 2 ++ src/main.rs | 1 + src/query.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/args.rs b/src/args.rs index 2d2affb..6dbb825 100644 --- a/src/args.rs +++ b/src/args.rs @@ -30,6 +30,7 @@ fn default_max_cell_length() -> usize { 1000 } + #[derive(Clone, Debug, Options, Deserialize, Serialize)] pub struct Args { #[options(help = "Run a single command and exit")] @@ -109,6 +110,7 @@ pub struct Args { #[serde(default = "default_max_cell_length")] pub max_cell_length: usize, + #[options(no_short, help = "Update default configuration values")] #[serde(skip_serializing, skip_deserializing)] pub update_defaults: bool, diff --git a/src/context.rs b/src/context.rs index 0f7239a..6625d7d 100644 --- a/src/context.rs +++ b/src/context.rs @@ -19,6 +19,7 @@ pub struct Context { pub prompt3: Option, pub last_result: Option, pub last_stats: Option, + pub is_interactive: bool, } impl Context { @@ -33,6 +34,7 @@ impl Context { prompt3: None, last_result: None, last_stats: None, + is_interactive: false, } } diff --git a/src/main.rs b/src/main.rs index e4b5aa8..af201a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ async fn main() -> Result<(), Box> { } let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); + context.is_interactive = is_tty; let mut rl = DefaultEditor::new()?; let history_path = history_path()?; diff --git a/src/query.rs b/src/query.rs index 6181f53..e40bc66 100644 --- a/src/query.rs +++ b/src/query.rs @@ -54,6 +54,41 @@ fn format_number(n: u64) -> String { result.chars().rev().collect() } +const INTERACTIVE_MAX_ROWS: usize = 10_000; +const INTERACTIVE_MAX_BYTES: usize = 1_048_576; // 1 MB + +// Limit rows for interactive display, returning a slice and an optional truncation message. +// Bytes are estimated as the sum of JSON string lengths across all cells in a row. +fn apply_output_limits(rows: &[Vec]) -> (&[Vec], Option) { + let mut byte_count = 0usize; + for (i, row) in rows.iter().enumerate() { + if i >= INTERACTIVE_MAX_ROWS { + return ( + &rows[..i], + Some(format!( + "Showing {} of {} rows (use \\view to see all).", + format_number(i as u64), + format_number(rows.len() as u64), + )), + ); + } + for val in row { + byte_count += val.to_string().len(); + } + if byte_count > INTERACTIVE_MAX_BYTES { + return ( + &rows[..=i], + Some(format!( + "Showing {} of {} rows (use \\view to see all).", + format_number((i + 1) as u64), + format_number(rows.len() as u64), + )), + ); + } + } + (rows, None) +} + // Set parameters via query pub fn set_args(context: &mut Context, query: &str) -> Result> { // set flag = value; @@ -254,29 +289,45 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< .map(|(terminal_size::Width(w), _)| w) .unwrap_or(80); + let max_cell_length = if parsed.columns.len() == 1 { + context.args.max_cell_length * 5 + } else { + context.args.max_cell_length + }; + + let (display_rows, limit_msg) = if context.is_interactive { + apply_output_limits(&parsed.rows) + } else { + (&parsed.rows[..], None) + }; + let table_output = if context.args.is_horizontal_display() { // Force horizontal table layout - table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) + table_renderer::render_table(&parsed.columns, display_rows, max_cell_length) } else if context.args.is_vertical_display() { // Force vertical two-column layout - table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, context.args.max_cell_length) + table_renderer::render_table_vertical(&parsed.columns, display_rows, terminal_width, max_cell_length) } else if context.args.is_auto_display() { // Auto mode - intelligently choose display mode if table_renderer::should_use_vertical_mode(&parsed.columns, terminal_width, context.args.min_col_width) { if context.args.verbose { eprintln!("Note: Using vertical display mode (table too wide for horizontal display)"); } - table_renderer::render_table_vertical(&parsed.columns, &parsed.rows, terminal_width, context.args.max_cell_length) + table_renderer::render_table_vertical(&parsed.columns, display_rows, terminal_width, max_cell_length) } else { - table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) + table_renderer::render_table(&parsed.columns, display_rows, max_cell_length) } } else { // Fallback to horizontal if format starts with client: but mode not recognized - table_renderer::render_table(&parsed.columns, &parsed.rows, context.args.max_cell_length) + table_renderer::render_table(&parsed.columns, display_rows, max_cell_length) }; println!("{}", table_output); + if let Some(msg) = limit_msg { + eprintln!("{}", msg); + } + // Store statistics for display later (after Time) context.last_stats = if !context.args.concise && parsed.statistics.is_some() { parsed.statistics.as_ref().and_then(|stats| { From 47ae280cb7c57e29010413f2d011bcd18c3c8c2a Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sat, 7 Feb 2026 13:54:49 +0100 Subject: [PATCH 022/147] Add SQL syntax highlighting to interactive REPL Implements regex-based syntax highlighting for SQL queries in the interactive REPL mode with industry-standard color scheme. Features: - Keywords (SELECT, FROM, WHERE): Bright Blue - Functions (COUNT, AVG): Bright Cyan - Strings ('text'): Bright Yellow - Numbers (42, 3.14): Bright Magenta - Comments (-- text): Bright Black (gray) - Operators: Default (subtle) Configuration: - Auto-enabled in interactive TTY mode - Disabled via --no-color flag - Respects NO_COLOR environment variable - Auto-disabled for piped/redirected output Implementation: - Regex-based highlighting (no new dependencies) - 13 comprehensive unit tests - Graceful error handling - Colorblind-accessible color scheme based on DuckDB, pgcli, and accessibility research Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 13 +- src/args.rs | 26 +++- src/highlight.rs | 321 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 24 +++- src/meta_commands.rs | 62 ++++---- src/repl_helper.rs | 56 ++++++++ src/table_renderer.rs | 37 ++--- src/viewer.rs | 7 +- tests/cli.rs | 9 +- 9 files changed, 470 insertions(+), 85 deletions(-) create mode 100644 src/highlight.rs create mode 100644 src/repl_helper.rs diff --git a/Cargo.lock b/Cargo.lock index d0eba49..ecc1ddd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,7 +810,6 @@ dependencies = [ "csvlens", "dirs", "gumdrop", - "home 0.4.2", "once_cell", "openssl", "pest", @@ -1061,16 +1060,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "home" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "013e4e6e9134211bb4d6bf53dd8cfb75d9e2715cc33614b9c0827718c6fbe0b8" -dependencies = [ - "scopeguard", - "winapi", -] - [[package]] name = "home" version = "0.5.12" @@ -2245,7 +2234,7 @@ dependencies = [ "cfg-if", "clipboard-win 4.5.0", "fd-lock", - "home 0.5.12", + "home", "libc", "log", "memchr", diff --git a/src/args.rs b/src/args.rs index 6dbb825..04bd2ff 100644 --- a/src/args.rs +++ b/src/args.rs @@ -77,7 +77,6 @@ pub struct Args { #[serde(default)] pub jwt_from_file: bool, - #[options( no_short, help = "OAuth environment to use (e.g., 'app' or 'staging'). Used for Service Account authentication", @@ -115,6 +114,10 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub update_defaults: bool, + #[options(no_short, help = "Disable syntax highlighting in REPL")] + #[serde(default)] + pub no_color: bool, + #[options(help = "Print version")] #[serde(default)] pub version: bool, @@ -138,7 +141,7 @@ impl Args { /// "client:auto" → "auto", "client:vertical" → "vertical", "PSQL" → "" pub fn get_display_mode(&self) -> &str { if self.format.starts_with("client:") { - &self.format[7..] // Skip "client:" prefix + &self.format[7..] // Skip "client:" prefix } else { "" } @@ -155,6 +158,22 @@ impl Args { pub fn is_auto_display(&self) -> bool { self.get_display_mode().eq_ignore_ascii_case("auto") } + + /// Determine if colors should be used for syntax highlighting + pub fn should_use_colors(&self) -> bool { + // Check NO_COLOR environment variable (standard: no-color.org) + if std::env::var("NO_COLOR").is_ok() { + return false; + } + + // Check command-line flag + if self.no_color { + return false; + } + + // Default: use colors + true + } } pub fn normalize_extras(extras: Vec, encode: bool) -> Result, Box> { @@ -283,7 +302,8 @@ pub fn get_args() -> Result> { // Warn if user specified a client format name without the "client:" prefix if args.format.eq_ignore_ascii_case("auto") || args.format.eq_ignore_ascii_case("vertical") - || args.format.eq_ignore_ascii_case("horizontal") { + || args.format.eq_ignore_ascii_case("horizontal") + { eprintln!("Warning: Format '{}' is not supported by the server.", args.format); eprintln!("Did you mean '--format client:{}'?", args.format.to_lowercase()); eprintln!("Client-side formats require the 'client:' prefix (e.g., client:auto, client:vertical, client:horizontal)"); diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 0000000..dbc952b --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,321 @@ +use once_cell::sync::Lazy; +use regex::Regex; +use rustyline::highlight::Highlighter; +use std::borrow::Cow; + +/// Color scheme using ANSI escape codes +pub struct ColorScheme { + keyword: &'static str, + function: &'static str, + string: &'static str, + number: &'static str, + comment: &'static str, + operator: &'static str, + reset: &'static str, +} + +impl ColorScheme { + fn new() -> Self { + Self { + keyword: "\x1b[94m", // Bright Blue - SQL keywords stand out clearly + function: "\x1b[96m", // Bright Cyan - distinct from keywords + string: "\x1b[93m", // Bright Yellow - conventional choice (DuckDB, pgcli, VSCode) + number: "\x1b[95m", // Bright Magenta - clear distinction + comment: "\x1b[90m", // Bright Black (gray) - better visibility than dim + operator: "\x1b[0m", // Default/Reset - subtle, doesn't compete visually + reset: "\x1b[0m", // Reset All + } + } +} + +// SQL Keywords pattern +static KEYWORD_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|OUTER|FULL|ON|AND|OR|NOT|IN|IS|NULL|LIKE|BETWEEN|GROUP|BY|HAVING|ORDER|ASC|DESC|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|ALTER|DROP|TABLE|VIEW|INDEX|AS|DISTINCT|ALL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|RETURNING|CAST|EXTRACT|INTERVAL|EXISTS|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|DEFAULT|UNIQUE|CHECK|CROSS)\b").unwrap() +}); + +// SQL Functions pattern +static FUNCTION_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(COUNT|SUM|AVG|MIN|MAX|COALESCE|NULLIF|CONCAT|SUBSTRING|LENGTH|UPPER|LOWER|TRIM|ROUND|FLOOR|CEIL|ABS|NOW|CURRENT_DATE|CURRENT_TIME|CURRENT_TIMESTAMP|DATE_TRUNC|EXTRACT)\s*\(").unwrap() +}); + +// String pattern (single quotes with escape sequences) +static STRING_PATTERN: Lazy = Lazy::new(|| Regex::new(r"'(?:[^']|''|\\')*'").unwrap()); + +// Number pattern +static NUMBER_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\b\d+\.?\d*([eE][+-]?\d+)?\b").unwrap()); + +// Comment patterns +static LINE_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"--[^\n]*").unwrap()); + +static BLOCK_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"/\*[\s\S]*?\*/").unwrap()); + +// Operator pattern +static OPERATOR_PATTERN: Lazy = Lazy::new(|| Regex::new(r"[=<>!]+|[+\-*/%]|\|\||::").unwrap()); + +/// SQL syntax highlighter using regex patterns +pub struct SqlHighlighter { + color_scheme: ColorScheme, + enabled: bool, +} + +impl SqlHighlighter { + /// Create a new SQL highlighter + /// + /// # Arguments + /// * `enabled` - Whether syntax highlighting should be enabled + pub fn new(enabled: bool) -> Result> { + Ok(Self { + color_scheme: ColorScheme::new(), + enabled, + }) + } + + /// Highlight SQL text by applying ANSI color codes + fn highlight_sql(&self, line: &str) -> String { + if !self.enabled || line.is_empty() { + return line.to_string(); + } + + // Collect all matches with their positions and colors + let mut highlights: Vec<(usize, usize, &str)> = Vec::new(); + + // Match comments first (highest priority to prevent highlighting within comments) + for mat in LINE_COMMENT_PATTERN.find_iter(line) { + highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); + } + for mat in BLOCK_COMMENT_PATTERN.find_iter(line) { + highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); + } + + // Match strings (high priority to prevent highlighting keywords in strings) + for mat in STRING_PATTERN.find_iter(line) { + highlights.push((mat.start(), mat.end(), self.color_scheme.string)); + } + + // Collect other matches separately to avoid borrow checker issues + let mut keyword_matches = Vec::new(); + for mat in KEYWORD_PATTERN.find_iter(line) { + keyword_matches.push((mat.start(), mat.end())); + } + + let mut function_matches = Vec::new(); + for mat in FUNCTION_PATTERN.find_iter(line) { + // Don't include the opening parenthesis + function_matches.push((mat.start(), mat.end() - 1)); + } + + let mut number_matches = Vec::new(); + for mat in NUMBER_PATTERN.find_iter(line) { + number_matches.push((mat.start(), mat.end())); + } + + let mut operator_matches = Vec::new(); + for mat in OPERATOR_PATTERN.find_iter(line) { + operator_matches.push((mat.start(), mat.end())); + } + + // Add matches that don't overlap with existing highlights (strings/comments) + for (start, end) in keyword_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.keyword)); + } + } + + for (start, end) in function_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.function)); + } + } + + for (start, end) in number_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.number)); + } + } + + for (start, end) in operator_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.operator)); + } + } + + // Sort highlights by start position + highlights.sort_by_key(|h| h.0); + + // Build highlighted string by inserting ANSI codes + let mut result = String::with_capacity(line.len() * 2); + let mut last_pos = 0; + let reset = self.color_scheme.reset; + + for (start, end, color) in highlights { + // Skip overlapping highlights (shouldn't happen but just in case) + if start < last_pos { + continue; + } + + // Add text before highlight + if start > last_pos { + result.push_str(&line[last_pos..start]); + } + + // Add colored text + result.push_str(color); + result.push_str(&line[start..end]); + result.push_str(reset); + + last_pos = end; + } + + // Add remaining text + if last_pos < line.len() { + result.push_str(&line[last_pos..]); + } + + result + } +} + +impl Highlighter for SqlHighlighter { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + if !self.enabled { + return Cow::Borrowed(line); + } + + let highlighted = self.highlight_sql(line); + if highlighted == line { + Cow::Borrowed(line) + } else { + Cow::Owned(highlighted) + } + } + + fn highlight_char(&self, _line: &str, _pos: usize) -> bool { + self.enabled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_disabled_highlighter() { + let highlighter = SqlHighlighter::new(false).unwrap(); + let result = highlighter.highlight("SELECT * FROM users", 0); + assert_eq!(result, "SELECT * FROM users"); + } + + #[test] + fn test_keyword_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT"); + assert!(result.contains("\x1b[94m")); // Contains blue color code + assert!(result.contains("SELECT")); + assert!(result.contains("\x1b[0m")); // Contains reset code + } + + #[test] + fn test_string_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 'hello'"); + assert!(result.contains("\x1b[93m")); // Contains yellow color code for string + } + + #[test] + fn test_number_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 42"); + assert!(result.contains("\x1b[95m")); // Contains magenta color code for number + } + + #[test] + fn test_comment_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("-- comment"); + assert!(result.contains("\x1b[90m")); // Contains bright black (gray) color code + } + + #[test] + fn test_function_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT COUNT(*)"); + assert!(result.contains("\x1b[96m")); // Contains cyan color code for function + } + + #[test] + fn test_complex_query() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let query = "SELECT id, name FROM users WHERE status = 'active'"; + let result = highlighter.highlight_sql(query); + + // Should contain keyword colors (blue) + assert!(result.contains("\x1b[94m")); + // Should contain string colors (yellow) + assert!(result.contains("\x1b[93m")); + // Should contain reset codes + assert!(result.contains("\x1b[0m")); + } + + #[test] + fn test_keywords_in_strings_not_highlighted() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 'SELECT FROM WHERE'"); + + // Count the number of keyword color codes - should only be one (for the first SELECT) + let keyword_count = result.matches("\x1b[94m").count(); + assert_eq!(keyword_count, 1); + } + + #[test] + fn test_malformed_sql_graceful() { + let highlighter = SqlHighlighter::new(true).unwrap(); + // This might not parse perfectly, but should not crash + let result = highlighter.highlight_sql("SELECT FROM WHERE"); + // Should still highlight keywords even in malformed SQL + assert!(result.contains("\x1b[94m")); + } + + #[test] + fn test_empty_string() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql(""); + assert_eq!(result, ""); + } + + #[test] + fn test_multiline_fragment() { + let highlighter = SqlHighlighter::new(true).unwrap(); + // Each line is highlighted independently in REPL + let result = highlighter.highlight_sql("FROM users"); + assert!(result.contains("\x1b[94m")); // FROM should be highlighted + } + + #[test] + fn test_operators() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("WHERE x = 1 AND y > 2"); + // Operators are now subtle (default color) but query should highlight successfully + assert!(result.contains("\x1b[94m")); // WHERE and AND should be highlighted as keywords + assert!(result.contains("=")); // Operators should still be present + assert!(result.contains(">")); + } + + #[test] + fn test_block_comment() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT /* comment */ 1"); + assert!(result.contains("\x1b[90m")); // Should contain bright black (gray) color code + } +} diff --git a/src/main.rs b/src/main.rs index af201a4..e89d802 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,13 @@ -use rustyline::{config::Configurer, error::ReadlineError, Cmd, DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers}; +use rustyline::{config::Configurer, error::ReadlineError, Cmd, Editor, EventHandler, KeyCode, KeyEvent, Modifiers}; use std::io::IsTerminal; mod args; mod auth; mod context; +mod highlight; mod meta_commands; mod query; +mod repl_helper; mod table_renderer; mod utils; mod viewer; @@ -13,8 +15,10 @@ mod viewer; use args::get_args; use auth::maybe_authenticate; use context::Context; +use highlight::SqlHighlighter; use meta_commands::handle_meta_command; use query::{query, try_split_queries}; +use repl_helper::ReplHelper; use utils::history_path; use viewer::open_csvlens_viewer; @@ -48,7 +52,21 @@ async fn main() -> Result<(), Box> { let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); context.is_interactive = is_tty; - let mut rl = DefaultEditor::new()?; + // Determine if colors should be used + let should_highlight = is_tty && context.args.should_use_colors(); + + // Initialize highlighter with error handling + let highlighter = SqlHighlighter::new(should_highlight).unwrap_or_else(|e| { + if context.args.verbose { + eprintln!("Failed to initialize syntax highlighting: {}", e); + } + SqlHighlighter::new(false).unwrap() // Fallback to disabled + }); + + let helper = ReplHelper::new(highlighter); + let mut rl: Editor = Editor::new()?; + rl.set_helper(Some(helper)); + let history_path = history_path()?; rl.set_max_history_size(10_000)?; if rl.load_history(&history_path).is_err() { @@ -66,7 +84,7 @@ async fn main() -> Result<(), Box> { // Instead, we'll keep the two-step approach (Ctrl-V + Enter) which is explicit and clear rl.bind_sequence( KeyEvent(KeyCode::Char('v'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())) + EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())), ); if is_tty && !context.args.concise { diff --git a/src/meta_commands.rs b/src/meta_commands.rs index d4f604c..bcaaaaa 100644 --- a/src/meta_commands.rs +++ b/src/meta_commands.rs @@ -1,6 +1,6 @@ use crate::context::Context; -use regex::Regex; use once_cell::sync::Lazy; +use regex::Regex; // Handle meta-commands (backslash commands) pub fn handle_meta_command(context: &mut Context, command: &str) -> Result> { @@ -45,9 +45,7 @@ pub fn handle_meta_command(context: &mut Context, command: &str) -> Result Option { - static SET_PROMPT_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^\s*\\set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap() - }); + static SET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*\\set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap()); if let Some(captures) = SET_PROMPT_RE.captures(command) { // Check if the prompt type matches @@ -70,9 +68,7 @@ fn parse_set_prompt(command: &str, prompt_type: &str) -> Option { // Generic function to parse \unset PROMPT command fn parse_unset_prompt(command: &str, prompt_type: &str) -> bool { - static UNSET_PROMPT_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^\s*\\unset\s+(\w+)\s*$"#).unwrap() - }); + static UNSET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*\\unset\s+(\w+)\s*$"#).unwrap()); if let Some(captures) = UNSET_PROMPT_RE.captures(command) { if let Some(cmd_prompt_type) = captures.get(1) { @@ -92,7 +88,7 @@ mod tests { fn test_set_prompt1_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT1 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -103,7 +99,7 @@ mod tests { fn test_set_prompt1_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT1 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -114,7 +110,7 @@ mod tests { fn test_set_prompt1_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT1 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -125,7 +121,7 @@ mod tests { fn test_set_prompt2_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT2 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -136,7 +132,7 @@ mod tests { fn test_set_prompt2_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT2 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -147,7 +143,7 @@ mod tests { fn test_set_prompt2_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT2 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -158,7 +154,7 @@ mod tests { fn test_set_prompt3_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT3 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -169,7 +165,7 @@ mod tests { fn test_set_prompt3_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT3 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -180,7 +176,7 @@ mod tests { fn test_set_prompt3_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT3 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -191,11 +187,11 @@ mod tests { fn test_unset_prompt1() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt1("test> ".to_string()); assert_eq!(context.prompt1, Some("test> ".to_string())); - + // Then unset it let command = r#"\unset PROMPT1"#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -207,11 +203,11 @@ mod tests { fn test_unset_prompt2() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt2("test> ".to_string()); assert_eq!(context.prompt2, Some("test> ".to_string())); - + // Then unset it let command = r#"\unset PROMPT2"#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -223,11 +219,11 @@ mod tests { fn test_unset_prompt3() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt3("test> ".to_string()); assert_eq!(context.prompt3, Some("test> ".to_string())); - + // Then unset it let command = r#"\unset PROMPT3"#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -239,12 +235,12 @@ mod tests { fn test_invalid_commands() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Invalid commands should return false let command = r#"\invalid command"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); - + let command = r#"\set INVALID value"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); @@ -254,7 +250,7 @@ mod tests { fn test_whitespace_handling() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Test with various whitespace let command = r#" \set PROMPT1 'test>' "#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -266,25 +262,25 @@ mod tests { fn test_prompt_independence() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Set all three prompts to different values let command1 = r#"\set PROMPT1 'prompt1> '"#; let command2 = r#"\set PROMPT2 'prompt2> '"#; let command3 = r#"\set PROMPT3 'prompt3> '"#; - + handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); handle_meta_command(&mut context, command3).unwrap(); - + // Verify all prompts are set independently assert_eq!(context.prompt1, Some("prompt1> ".to_string())); assert_eq!(context.prompt2, Some("prompt2> ".to_string())); assert_eq!(context.prompt3, Some("prompt3> ".to_string())); - + // Unset only PROMPT2 let unset_command = r#"\unset PROMPT2"#; handle_meta_command(&mut context, unset_command).unwrap(); - + // Verify only PROMPT2 was unset assert_eq!(context.prompt1, Some("prompt1> ".to_string())); assert_eq!(context.prompt2, None); @@ -295,16 +291,16 @@ mod tests { fn test_case_insensitive_prompt_types() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Test case insensitive prompt type matching let command1 = r#"\set prompt1 'test1> '"#; let command2 = r#"\set Prompt2 'test2> '"#; let command3 = r#"\set PROMPT3 'test3> '"#; - + handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); handle_meta_command(&mut context, command3).unwrap(); - + // Verify all prompts are set correctly regardless of case assert_eq!(context.prompt1, Some("test1> ".to_string())); assert_eq!(context.prompt2, Some("test2> ".to_string())); diff --git a/src/repl_helper.rs b/src/repl_helper.rs new file mode 100644 index 0000000..7c48bbb --- /dev/null +++ b/src/repl_helper.rs @@ -0,0 +1,56 @@ +use crate::highlight::SqlHighlighter; +use rustyline::completion::Completer; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::Helper; +use std::borrow::Cow; + +/// REPL helper that integrates SqlHighlighter with rustyline +pub struct ReplHelper { + highlighter: SqlHighlighter, +} + +impl ReplHelper { + /// Create a new REPL helper with the given highlighter + pub fn new(highlighter: SqlHighlighter) -> Self { + Self { highlighter } + } +} + +// Implement the Helper trait (required) +impl Helper for ReplHelper {} + +// Implement empty Completer (no autocompletion for now) +impl Completer for ReplHelper { + type Candidate = String; +} + +// Implement empty Hinter (no hints for now) +impl Hinter for ReplHelper { + type Hint = String; +} + +// Implement empty Validator (accept all input) +impl Validator for ReplHelper {} + +// Delegate highlighting to SqlHighlighter +impl Highlighter for ReplHelper { + fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { + self.highlighter.highlight(line, pos) + } + + fn highlight_char(&self, line: &str, pos: usize) -> bool { + self.highlighter.highlight_char(line, pos) + } + + fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> { + // Don't highlight the prompt itself + Cow::Borrowed(prompt) + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + // Don't highlight hints + Cow::Borrowed(hint) + } +} diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 95a7aec..4c30041 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -95,9 +95,7 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec], max_value_len table.set_content_arrangement(ContentArrangement::Dynamic); // Detect terminal width and calculate equal column widths - let terminal_width = terminal_size() - .map(|(Width(w), _)| w) - .unwrap_or(80); + let terminal_width = terminal_size().map(|(Width(w), _)| w).unwrap_or(80); table.set_width(terminal_width); @@ -197,12 +195,7 @@ fn format_value(value: &Value) -> String { /// Calculate the display width of a string, ignoring ANSI escape codes /// Render table in vertical format (two-column table with column names and values) /// Used when table is too wide for horizontal display in auto mode -pub fn render_table_vertical( - columns: &[ResultColumn], - rows: &[Vec], - terminal_width: u16, - max_value_length: usize, -) -> String { +pub fn render_table_vertical(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> String { let mut output = String::new(); for (row_idx, row) in rows.iter().enumerate() { @@ -218,8 +211,8 @@ pub fn render_table_vertical( // Second column (values): wide, allows wrapping let available_width = if terminal_width > 10 { terminal_width - 4 } else { 76 }; table.set_constraints(vec![ - ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(30)), // Column names - ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(available_width.saturating_sub(30))), // Values + ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(30)), // Column names + ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(available_width.saturating_sub(30))), // Values ]); // Add rows (no header - just column name | value pairs) @@ -235,9 +228,7 @@ pub fn render_table_vertical( }; // Column name cell (cyan, bold) - let name_cell = Cell::new(&col.name) - .fg(Color::Cyan) - .add_attribute(Attribute::Bold); + let name_cell = Cell::new(&col.name).fg(Color::Cyan).add_attribute(Attribute::Bold); // Value cell - color NULL values differently to distinguish from string "NULL" let value_cell = if row[col_idx].is_null() { @@ -308,11 +299,7 @@ pub fn write_result_as_csv( rows: &[Vec], ) -> Result<(), Box> { // Write CSV header - let header = columns - .iter() - .map(|col| escape_csv_field(&col.name)) - .collect::>() - .join(","); + let header = columns.iter().map(|col| escape_csv_field(&col.name)).collect::>().join(","); writeln!(writer, "{}", header)?; // Write data rows @@ -592,12 +579,10 @@ mod tests { #[test] fn test_render_vertical_value_truncation() { - let columns = vec![ - ResultColumn { - name: "long_col".to_string(), - column_type: "text".to_string(), - }, - ]; + let columns = vec![ResultColumn { + name: "long_col".to_string(), + column_type: "text".to_string(), + }]; let long_value = "a".repeat(2000); // 2000 characters let rows = vec![vec![Value::String(long_value)]]; @@ -785,7 +770,7 @@ mod tests { }, ]; let rows = vec![ - vec![Value::Number(1.into()), Value::Null], // Real NULL + vec![Value::Number(1.into()), Value::Null], // Real NULL vec![Value::Number(2.into()), Value::String("NULL".to_string())], // String "NULL" vec![Value::Number(3.into()), Value::String("test".to_string())], // Regular string ]; diff --git a/src/viewer.rs b/src/viewer.rs index 83f95ee..73d2d88 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -165,7 +165,12 @@ mod tests { // verify the file creation part let csv_path = temp_csv_path().unwrap(); let mut file = File::create(&csv_path).unwrap(); - write_result_as_csv(&mut file, &context.last_result.as_ref().unwrap().columns, &context.last_result.as_ref().unwrap().rows).unwrap(); + write_result_as_csv( + &mut file, + &context.last_result.as_ref().unwrap().columns, + &context.last_result.as_ref().unwrap().rows, + ) + .unwrap(); drop(file); // Verify file exists and has content diff --git a/tests/cli.rs b/tests/cli.rs index 74efbaa..e6db6d8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ +use serde_json; use std::io::Write; use std::process::Command; -use serde_json; fn run_fb(args: &[&str]) -> (bool, String, String) { let output = Command::new(env!("CARGO_BIN_EXE_fb")) @@ -181,12 +181,7 @@ fn test_command_parsing() { #[test] fn test_exiting() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&[ - "--core", - "--concise", - "-f", - "TabSeparatedWithNamesAndTypes", - ]) + .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() From 0f775946c16134b0cfc6b461b29c20ea5eebb2d6 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sat, 7 Feb 2026 13:54:49 +0100 Subject: [PATCH 023/147] Add SQL syntax highlighting to interactive REPL Implements regex-based syntax highlighting for SQL queries in the interactive REPL mode with industry-standard color scheme. Features: - Keywords (SELECT, FROM, WHERE): Bright Blue - Functions (COUNT, AVG): Bright Cyan - Strings ('text'): Bright Yellow - Numbers (42, 3.14): Bright Magenta - Comments (-- text): Bright Black (gray) - Operators: Default (subtle) Configuration: - Auto-enabled in interactive TTY mode - Disabled via --no-color flag - Respects NO_COLOR environment variable - Auto-disabled for piped/redirected output Implementation: - Regex-based highlighting (no new dependencies) - 13 comprehensive unit tests - Graceful error handling - Colorblind-accessible color scheme based on DuckDB, pgcli, and accessibility research Co-Authored-By: Claude Sonnet 4.5 --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index e89d802..de62548 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,7 @@ async fn main() -> Result<(), Box> { let mut rl: Editor = Editor::new()?; rl.set_helper(Some(helper)); + let history_path = history_path()?; rl.set_max_history_size(10_000)?; if rl.load_history(&history_path).is_err() { From 00e64f2ed9f8aa08cdaaef51a3664d6943b1b7de Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sat, 7 Feb 2026 16:01:13 +0100 Subject: [PATCH 024/147] Add SQL auto-completion for tables and columns in REPL Implement context-aware auto-completion that suggests table names and column names from the database schema. The completion system queries information_schema on startup and caches results for fast lookups. Key features: - Auto-complete table names and column names - Async schema cache refresh (non-blocking startup) - Support for message-based query response format - Configurable via --no-completion and --completion-cache-ttl flags - Runtime control with 'set completion = on/off' - Manual refresh with \refresh command Implementation details: - New completion module with SqlCompleter, SchemaCache, and context detector - Queries information_schema.tables, columns, and routines - Thread-safe cache using Arc> - Graceful error handling and fallback Co-Authored-By: Claude Sonnet 4.5 --- src/args.rs | 9 + src/completion/context_detector.rs | 267 ++++++++++++++ src/completion/mod.rs | 101 ++++++ src/completion/schema_cache.rs | 562 +++++++++++++++++++++++++++++ src/context.rs | 1 + src/main.rs | 54 ++- src/query.rs | 40 ++ src/repl_helper.rs | 35 +- 8 files changed, 1058 insertions(+), 11 deletions(-) create mode 100644 src/completion/context_detector.rs create mode 100644 src/completion/mod.rs create mode 100644 src/completion/schema_cache.rs diff --git a/src/args.rs b/src/args.rs index 04bd2ff..4ed9b92 100644 --- a/src/args.rs +++ b/src/args.rs @@ -31,6 +31,7 @@ fn default_max_cell_length() -> usize { } + #[derive(Clone, Debug, Options, Deserialize, Serialize)] pub struct Args { #[options(help = "Run a single command and exit")] @@ -118,6 +119,14 @@ pub struct Args { #[serde(default)] pub no_color: bool, + #[options(no_short, help = "Disable auto-completion in REPL")] + #[serde(default)] + pub no_completion: bool, + + #[options(no_short, help = "Schema cache TTL in seconds (default: 300)")] + #[serde(default = "default_cache_ttl")] + pub completion_cache_ttl: u64, + #[options(help = "Print version")] #[serde(default)] pub version: bool, diff --git a/src/completion/context_detector.rs b/src/completion/context_detector.rs new file mode 100644 index 0000000..42ccac3 --- /dev/null +++ b/src/completion/context_detector.rs @@ -0,0 +1,267 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +static KEYWORD_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|FULL|OUTER|ON|GROUP|ORDER|BY|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|DROP|ALTER|TABLE|DATABASE|INDEX|VIEW|AS|DISTINCT|ALL|AND|OR|NOT|IN|EXISTS|BETWEEN|LIKE|IS|NULL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE)\b").unwrap() +}); + +#[derive(Debug, PartialEq, Clone)] +pub enum CompletionContext { + Keyword, // Start of statement or after whitespace + TableName, // After FROM, JOIN, INTO, UPDATE + ColumnName, // After SELECT, WHERE, ON, GROUP BY, ORDER BY + FunctionName, // At start or after operators + SchemaQualified, // After "schema_name." + Nothing, // Inside string/comment, no completion +} + +/// Detects what type of completion is appropriate at the given position +pub fn detect_context(line: &str, pos: usize) -> CompletionContext { + // Clamp position to line length + let pos = pos.min(line.len()); + + // Don't complete inside strings or comments + if is_inside_string_or_comment(line, pos) { + return CompletionContext::Nothing; + } + + // Find the start of the current word + let word_start = find_word_start(line, pos); + let partial = &line[word_start..pos]; + + // If there's a dot in the partial word, it's schema-qualified + if partial.contains('.') { + return CompletionContext::SchemaQualified; + } + + // Get the context before the current word + let context_before = line[..word_start].trim().to_uppercase(); + + // If there's no context before and we're typing something, it's a keyword + if context_before.is_empty() { + return CompletionContext::Keyword; + } + + // Find the last keyword before our position + let last_keyword = find_last_keyword(&context_before); + + // Determine context based on last keyword + match last_keyword.as_deref() { + Some("FROM") | Some("JOIN") | Some("INTO") | Some("UPDATE") => { + CompletionContext::TableName + } + Some("SELECT") => { + // After SELECT, if we've seen other tokens (like *), we might be typing FROM + // Check if there are non-keyword tokens after SELECT + let after_select = context_before + .split("SELECT") + .last() + .unwrap_or("") + .trim(); + + // If there's something after SELECT (like * or column names), the next word could be a keyword + // But we can't easily distinguish, so default to ColumnName (safer for usability) + CompletionContext::ColumnName + } + Some("WHERE") | Some("ON") | Some("HAVING") => { + CompletionContext::ColumnName + } + Some("GROUP") | Some("ORDER") => { + // Check if followed by BY + if context_before.ends_with("GROUP BY") || context_before.ends_with("ORDER BY") { + CompletionContext::ColumnName + } else { + CompletionContext::Keyword + } + } + Some("BY") => { + // Check if this is part of GROUP BY or ORDER BY + if context_before.contains("GROUP BY") || context_before.contains("ORDER BY") { + CompletionContext::ColumnName + } else { + CompletionContext::Keyword + } + } + _ => { + // Default to keyword completion + CompletionContext::Keyword + } + } +} + +/// Finds the start position of the word at the given position +pub fn find_word_start(line: &str, pos: usize) -> usize { + let bytes = line.as_bytes(); + let mut start = pos; + + while start > 0 { + let prev = start - 1; + let ch = bytes[prev] as char; + + // Word characters: alphanumeric, underscore, or dot (for schema.table) + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + start = prev; + } else { + break; + } + } + + start +} + +/// Finds the last SQL keyword in the given text +fn find_last_keyword(text: &str) -> Option { + KEYWORD_PATTERN + .find_iter(text) + .last() + .map(|m| m.as_str().to_uppercase()) +} + +/// Checks if the position is inside a string literal or comment +fn is_inside_string_or_comment(line: &str, pos: usize) -> bool { + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escape_next = false; + + for (i, ch) in line.char_indices() { + if i >= pos { + break; + } + + if escape_next { + escape_next = false; + continue; + } + + match ch { + '\\' if in_single_quote || in_double_quote => { + escape_next = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + '-' if !in_single_quote && !in_double_quote => { + // Check for line comment + if i + 1 < line.len() && line.as_bytes()[i + 1] == b'-' { + return true; // Rest of line is a comment + } + } + _ => {} + } + } + + in_single_quote || in_double_quote +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_table_context() { + assert_eq!( + detect_context("SELECT * FROM us", 17), + CompletionContext::TableName + ); + assert_eq!( + detect_context("SELECT * FROM users JOIN or", 28), + CompletionContext::TableName + ); + assert_eq!( + detect_context("INSERT INTO us", 14), + CompletionContext::TableName + ); + assert_eq!( + detect_context("UPDATE us", 9), + CompletionContext::TableName + ); + } + + #[test] + fn test_detect_column_context() { + assert_eq!( + detect_context("SELECT col", 10), + CompletionContext::ColumnName + ); + assert_eq!( + detect_context("SELECT * FROM users WHERE col", 29), + CompletionContext::ColumnName + ); + assert_eq!( + detect_context("SELECT * FROM users GROUP BY col", 32), + CompletionContext::ColumnName + ); + assert_eq!( + detect_context("SELECT * FROM users ORDER BY col", 32), + CompletionContext::ColumnName + ); + } + + #[test] + fn test_detect_keyword_context() { + assert_eq!(detect_context("SEL", 3), CompletionContext::Keyword); + // After "SELECT *", the completion context is ambiguous - could be column or keyword + // Our implementation defaults to ColumnName for better usability after SELECT + // (columns are more common than keywords after SELECT) + // If user types "SELECT * F" they'll see both FROM keyword and any matching columns + } + + #[test] + fn test_detect_schema_qualified() { + assert_eq!( + detect_context("SELECT * FROM public.us", 23), + CompletionContext::SchemaQualified + ); + } + + #[test] + fn test_ignore_strings() { + assert_eq!( + detect_context("SELECT 'FROM us", 15), + CompletionContext::Nothing + ); + assert_eq!( + detect_context("SELECT \"col", 11), + CompletionContext::Nothing + ); + } + + #[test] + fn test_ignore_comments() { + assert_eq!( + detect_context("-- SELECT FROM us", 17), + CompletionContext::Nothing + ); + } + + #[test] + fn test_find_word_start() { + assert_eq!(find_word_start("SELECT * FROM users", 19), 14); + assert_eq!(find_word_start("SELECT col", 10), 7); + assert_eq!(find_word_start("public.users", 12), 0); + assert_eq!(find_word_start("SELECT * FROM public.us", 23), 14); + } + + #[test] + fn test_find_last_keyword() { + assert_eq!( + find_last_keyword("SELECT * FROM"), + Some("FROM".to_string()) + ); + assert_eq!( + find_last_keyword("SELECT * FROM users WHERE"), + Some("WHERE".to_string()) + ); + assert_eq!(find_last_keyword("no keywords here"), None); + } + + #[test] + fn test_is_inside_string() { + assert!(is_inside_string_or_comment("'hello world", 8)); + assert!(!is_inside_string_or_comment("'hello' world", 10)); + assert!(is_inside_string_or_comment("\"hello world", 8)); + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs new file mode 100644 index 0000000..125c751 --- /dev/null +++ b/src/completion/mod.rs @@ -0,0 +1,101 @@ +pub mod context_detector; +pub mod schema_cache; + +use context_detector::{detect_context, find_word_start}; +use rustyline::completion::{Completer, Pair}; +use rustyline::Context as RustylineContext; +use schema_cache::SchemaCache; +use std::sync::Arc; + +pub struct SqlCompleter { + cache: Arc, + enabled: bool, +} + +impl SqlCompleter { + pub fn new(cache: Arc, enabled: bool) -> Self { + Self { cache, enabled } + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn cache(&self) -> &Arc { + &self.cache + } +} + +impl Completer for SqlCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &RustylineContext<'_>, + ) -> rustyline::Result<(usize, Vec)> { + if !self.enabled { + return Ok((0, Vec::new())); + } + + // Find the start of the word we're completing + let word_start = find_word_start(line, pos); + let partial = &line[word_start..pos]; + + // Return only tables and columns (no keywords) + let mut candidates = Vec::new(); + + // Add table names + candidates.extend(self.cache.get_tables(partial)); + + // Add column names + candidates.extend(self.cache.get_columns(partial)); + + // Convert to Pair format (display, replacement) + let pairs: Vec = candidates + .into_iter() + .map(|c| Pair { + display: c.clone(), + replacement: c, + }) + .collect(); + + Ok((word_start, pairs)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustyline::history::DefaultHistory; + + #[test] + fn test_completer_disabled() { + let cache = Arc::new(SchemaCache::new(300)); + let completer = SqlCompleter::new(cache, false); + + let history = DefaultHistory::new(); + let ctx = RustylineContext::new(&history); + let result = completer.complete("SELECT * FROM us", 17, &ctx).unwrap(); + + assert_eq!(result.1.len(), 0); + } + + #[test] + fn test_completer_no_keywords() { + let cache = Arc::new(SchemaCache::new(300)); + let completer = SqlCompleter::new(cache, true); + + let history = DefaultHistory::new(); + let ctx = RustylineContext::new(&history); + let result = completer.complete("SEL", 3, &ctx).unwrap(); + + // Should not return keywords (only tables and columns) + assert!(!result.1.iter().any(|p| p.display == "SELECT")); + } +} diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs new file mode 100644 index 0000000..8dd42c1 --- /dev/null +++ b/src/completion/schema_cache.rs @@ -0,0 +1,562 @@ +use crate::context::Context; +use crate::query::query_silent; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +pub struct TableMetadata { + pub schema_name: String, + pub table_name: String, + pub columns: Vec, +} + +#[derive(Clone)] +pub struct ColumnMetadata { + pub name: String, + pub data_type: String, +} + +pub struct SchemaCache { + tables: Arc>>, + functions: Arc>>, + keywords: HashSet, + last_refresh: Arc>>, + ttl: Duration, + refreshing: Arc>, +} + +impl SchemaCache { + pub fn new(ttl_seconds: u64) -> Self { + Self { + tables: Arc::new(RwLock::new(HashMap::new())), + functions: Arc::new(RwLock::new(HashSet::new())), + keywords: Self::load_keywords(), + last_refresh: Arc::new(RwLock::new(None)), + ttl: Duration::from_secs(ttl_seconds), + refreshing: Arc::new(RwLock::new(false)), + } + } + + fn load_keywords() -> HashSet { + let keywords = vec![ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", + "ON", "GROUP", "ORDER", "BY", "HAVING", "LIMIT", "OFFSET", "UNION", "INTERSECT", + "EXCEPT", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "DROP", + "ALTER", "TABLE", "DATABASE", "INDEX", "VIEW", "AS", "DISTINCT", "ALL", "AND", + "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE", "IS", "NULL", "CASE", "WHEN", + "THEN", "ELSE", "END", "WITH", "RECURSIVE", "ASC", "DESC", "COUNT", "SUM", "AVG", + "MIN", "MAX", "CAST", "SUBSTRING", "TRIM", "UPPER", "LOWER", "COALESCE", "NULLIF", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CHECK", "DEFAULT", "AUTO_INCREMENT", + "EXPLAIN", "DESCRIBE", "SHOW", "USE", "GRANT", "REVOKE", "COMMIT", "ROLLBACK", + "SAVEPOINT", "TRANSACTION", "BEGIN", "START", "TRUNCATE", "RENAME", "ADD", "MODIFY", + "COLUMN", "CONSTRAINT", "CASCADE", "RESTRICT", + ]; + + keywords.into_iter().map(String::from).collect() + } + + /// Checks if cache is stale based on TTL + pub fn is_stale(&self) -> bool { + let last_refresh = self.last_refresh.read().unwrap(); + match *last_refresh { + Some(instant) => instant.elapsed() > self.ttl, + None => true, // Never refreshed + } + } + + /// Returns true if a refresh is currently in progress + pub fn is_refreshing(&self) -> bool { + *self.refreshing.read().unwrap() + } + + /// Marks refresh as started + fn start_refresh(&self) { + *self.refreshing.write().unwrap() = true; + } + + /// Marks refresh as complete and updates timestamp + fn complete_refresh(&self) { + *self.refreshing.write().unwrap() = false; + *self.last_refresh.write().unwrap() = Some(Instant::now()); + } + + /// Get all keywords matching prefix + pub fn get_keywords(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + self.keywords + .iter() + .filter(|k| k.to_lowercase().starts_with(&prefix_lower)) + .cloned() + .collect() + } + + /// Get all table names matching prefix + pub fn get_tables(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + tables + .values() + .map(|t| { + if t.schema_name == "public" || t.schema_name.is_empty() { + t.table_name.clone() + } else { + format!("{}.{}", t.schema_name, t.table_name) + } + }) + .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + + /// Get all column names matching prefix + pub fn get_columns(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + let mut columns = HashSet::new(); + for table in tables.values() { + for column in &table.columns { + columns.insert(column.name.clone()); + } + } + columns + .into_iter() + .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + + /// Synchronous method to get completions from cache + pub fn get_completions( + &self, + context: super::context_detector::CompletionContext, + prefix: &str, + ) -> Vec { + use super::context_detector::CompletionContext; + + let prefix_lower = prefix.to_lowercase(); + + match context { + CompletionContext::Keyword => { + // Return keywords + self.keywords + .iter() + .filter(|k| k.to_lowercase().starts_with(&prefix_lower)) + .cloned() + .collect() + } + CompletionContext::TableName => { + // Return table names + let tables = self.tables.read().unwrap(); + tables + .values() + .map(|t| { + if t.schema_name == "public" || t.schema_name.is_empty() { + t.table_name.clone() + } else { + format!("{}.{}", t.schema_name, t.table_name) + } + }) + .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + CompletionContext::ColumnName => { + // Return column names from all tables + let tables = self.tables.read().unwrap(); + let mut columns = HashSet::new(); + for table in tables.values() { + for column in &table.columns { + columns.insert(column.name.clone()); + } + } + columns + .into_iter() + .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + CompletionContext::FunctionName => { + // Return function names + let functions = self.functions.read().unwrap(); + functions + .iter() + .filter(|f| f.to_lowercase().starts_with(&prefix_lower)) + .cloned() + .collect() + } + CompletionContext::SchemaQualified => { + // Handle schema.table completion + if let Some(dot_pos) = prefix.rfind('.') { + let schema = &prefix[..dot_pos]; + let table_prefix = &prefix[dot_pos + 1..]; + let table_prefix_lower = table_prefix.to_lowercase(); + + let tables = self.tables.read().unwrap(); + tables + .values() + .filter(|t| t.schema_name == schema) + .map(|t| format!("{}.{}", t.schema_name, t.table_name)) + .filter(|name| { + name.split('.').nth(1).unwrap_or("") + .to_lowercase() + .starts_with(&table_prefix_lower) + }) + .collect() + } else { + Vec::new() + } + } + CompletionContext::Nothing => Vec::new(), + } + } + + /// Async method to refresh schema from database + pub async fn refresh(&self, context: &mut Context) -> Result<(), Box> { + // Check if already refreshing + if self.is_refreshing() { + return Ok(()); + } + + self.start_refresh(); + + let result = self.do_refresh(context).await; + + if result.is_ok() { + self.complete_refresh(); + } else { + // Still mark as complete to avoid blocking future attempts + *self.refreshing.write().unwrap() = false; + } + + result + } + + async fn do_refresh(&self, context: &mut Context) -> Result<(), Box> { + // Query tables + let tables_query = "SELECT table_schema, table_name \ + FROM information_schema.tables \ + WHERE table_schema NOT IN ('information_schema', 'pg_catalog') \ + ORDER BY table_schema, table_name"; + + let tables_result = query_silent(context, tables_query).await; + + // Query columns + let columns_query = "SELECT table_schema, table_name, column_name, data_type \ + FROM information_schema.columns \ + WHERE table_schema NOT IN ('information_schema', 'pg_catalog') \ + ORDER BY table_schema, table_name, ordinal_position"; + + let columns_result = query_silent(context, columns_query).await; + + // Query functions + let functions_query = "SELECT routine_name \ + FROM information_schema.routines \ + WHERE routine_schema NOT IN ('information_schema', 'pg_catalog') \ + ORDER BY routine_name"; + + let functions_result = query_silent(context, functions_query).await; + + // Parse and populate cache + let mut new_tables = HashMap::new(); + + // Parse tables + match tables_result { + Ok(tables_output) => { + if let Some(table_list) = Self::parse_tables(&tables_output) { + for (schema, table) in table_list { + let key = format!("{}.{}", schema, table); + new_tables.insert( + key, + TableMetadata { + schema_name: schema, + table_name: table, + columns: Vec::new(), + }, + ); + } + } else { + eprintln!("Warning: Failed to parse tables from schema query"); + eprintln!("Output was: {}", &tables_output[..tables_output.len().min(500)]); + } + } + Err(e) => { + eprintln!("Warning: Tables query failed: {}", e); + } + } + + // Parse columns and add to tables + match columns_result { + Ok(columns_output) => { + if let Some(column_list) = Self::parse_columns(&columns_output) { + for (schema, table, column, data_type) in column_list { + let key = format!("{}.{}", schema, table); + if let Some(table_meta) = new_tables.get_mut(&key) { + table_meta.columns.push(ColumnMetadata { + name: column, + data_type, + }); + } + } + } else { + eprintln!("Warning: Failed to parse columns from schema query"); + } + } + Err(e) => { + eprintln!("Warning: Columns query failed: {}", e); + } + } + + // Update tables cache + let num_tables = new_tables.len(); + let num_columns: usize = new_tables.values().map(|t| t.columns.len()).sum(); + *self.tables.write().unwrap() = new_tables; + + // Parse functions + let mut num_functions = 0; + match functions_result { + Ok(functions_output) => { + if let Some(function_list) = Self::parse_functions(&functions_output) { + num_functions = function_list.len(); + *self.functions.write().unwrap() = function_list.into_iter().collect(); + } else { + eprintln!("Warning: Failed to parse functions from schema query"); + } + } + Err(e) => { + eprintln!("Warning: Functions query failed: {}", e); + } + } + + // Debug output + eprintln!("Schema cache loaded: {} tables, {} columns, {} functions", + num_tables, num_columns, num_functions); + + Ok(()) + } + + fn parse_tables(output: &str) -> Option> { + let mut result = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse message-based format + if let Ok(json) = serde_json::from_str::(line) { + // Check for DATA message type + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + // Extract data array: [["public","test"],["public","test_view"]] + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(row_array) = row.as_array() { + if row_array.len() >= 2 { + let schema = row_array[0].as_str().unwrap_or("").to_string(); + let table = row_array[1].as_str().unwrap_or("").to_string(); + if !schema.is_empty() && !table.is_empty() { + result.push((schema, table)); + } + } + } + } + } + } + } else { + // Try old JSONLines_Compact format for backwards compatibility + if let (Some(schema), Some(table)) = ( + json.get("table_schema").and_then(|v| v.as_str()), + json.get("table_name").and_then(|v| v.as_str()), + ) { + result.push((schema.to_string(), table.to_string())); + } + } + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } + + fn parse_columns(output: &str) -> Option> { + let mut result = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse message-based format + if let Ok(json) = serde_json::from_str::(line) { + // Check for DATA message type + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + // Extract data array: [["public","test","id","integer"],...] + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(row_array) = row.as_array() { + if row_array.len() >= 4 { + let schema = row_array[0].as_str().unwrap_or("").to_string(); + let table = row_array[1].as_str().unwrap_or("").to_string(); + let column = row_array[2].as_str().unwrap_or("").to_string(); + let data_type = row_array[3].as_str().unwrap_or("").to_string(); + if !schema.is_empty() && !table.is_empty() && !column.is_empty() { + result.push((schema, table, column, data_type)); + } + } + } + } + } + } + } else { + // Try old JSONLines_Compact format for backwards compatibility + if let (Some(schema), Some(table), Some(column), Some(data_type)) = ( + json.get("table_schema").and_then(|v| v.as_str()), + json.get("table_name").and_then(|v| v.as_str()), + json.get("column_name").and_then(|v| v.as_str()), + json.get("data_type").and_then(|v| v.as_str()), + ) { + result.push(( + schema.to_string(), + table.to_string(), + column.to_string(), + data_type.to_string(), + )); + } + } + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } + + fn parse_functions(output: &str) -> Option> { + let mut result = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse message-based format + if let Ok(json) = serde_json::from_str::(line) { + // Check for DATA message type + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + // Extract data array: [["function1"],["function2"],...] + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(row_array) = row.as_array() { + if !row_array.is_empty() { + if let Some(routine_name) = row_array[0].as_str() { + if !routine_name.is_empty() { + result.push(routine_name.to_string()); + } + } + } + } + } + } + } + } else { + // Try old JSONLines_Compact format for backwards compatibility + if let Some(routine_name) = json.get("routine_name").and_then(|v| v.as_str()) { + result.push(routine_name.to_string()); + } + } + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tables() { + // Test new message-based format + let output = r#"{"message_type":"START","result_columns":[{"name":"table_schema","type":"text"},{"name":"table_name","type":"text"}]} +{"message_type":"DATA","data":[["public","users"],["public","orders"]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{}}"#; + let tables = SchemaCache::parse_tables(output).unwrap(); + assert_eq!(tables.len(), 2); + assert_eq!(tables[0], ("public".to_string(), "users".to_string())); + assert_eq!(tables[1], ("public".to_string(), "orders".to_string())); + + // Test old JSONLines_Compact format for backwards compatibility + let output_old = r#"{"table_schema":"public","table_name":"legacy_table"}"#; + let tables_old = SchemaCache::parse_tables(output_old).unwrap(); + assert_eq!(tables_old.len(), 1); + assert_eq!(tables_old[0], ("public".to_string(), "legacy_table".to_string())); + } + + #[test] + fn test_parse_columns() { + // Test new message-based format + let output = r#"{"message_type":"START","result_columns":[]} +{"message_type":"DATA","data":[["public","users","id","INTEGER"],["public","users","name","TEXT"]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{}}"#; + let columns = SchemaCache::parse_columns(output).unwrap(); + assert_eq!(columns.len(), 2); + assert_eq!( + columns[0], + ( + "public".to_string(), + "users".to_string(), + "id".to_string(), + "INTEGER".to_string() + ) + ); + } + + #[test] + fn test_keywords_loaded() { + let cache = SchemaCache::new(300); + assert!(cache.keywords.contains("SELECT")); + assert!(cache.keywords.contains("FROM")); + assert!(cache.keywords.contains("WHERE")); + } + + #[test] + fn test_is_stale() { + let cache = SchemaCache::new(1); // 1 second TTL + assert!(cache.is_stale()); // Never refreshed + + cache.complete_refresh(); + assert!(!cache.is_stale()); + + std::thread::sleep(Duration::from_millis(1100)); + assert!(cache.is_stale()); + } + + #[test] + fn test_get_keywords() { + let cache = SchemaCache::new(300); + let keywords = cache.get_keywords("SEL"); + assert!(keywords.contains(&"SELECT".to_string())); + } + + #[test] + fn test_empty_cache() { + let cache = SchemaCache::new(300); + // Cache should have keywords even when empty + assert!(cache.get_keywords("").len() > 0); + // But no tables or columns yet + assert_eq!(cache.get_tables("").len(), 0); + assert_eq!(cache.get_columns("").len(), 0); + } +} diff --git a/src/context.rs b/src/context.rs index 6625d7d..1d6fced 100644 --- a/src/context.rs +++ b/src/context.rs @@ -10,6 +10,7 @@ pub struct ServiceAccountToken { pub until: u64, } +#[derive(Clone)] pub struct Context { pub args: Args, pub url: String, diff --git a/src/main.rs b/src/main.rs index de62548..e59b916 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::io::IsTerminal; mod args; mod auth; +mod completion; mod context; mod highlight; mod meta_commands; @@ -14,11 +15,14 @@ mod viewer; use args::get_args; use auth::maybe_authenticate; +use completion::schema_cache::SchemaCache; +use completion::SqlCompleter; use context::Context; use highlight::SqlHighlighter; use meta_commands::handle_meta_command; use query::{query, try_split_queries}; use repl_helper::ReplHelper; +use std::sync::Arc; use utils::history_path; use viewer::open_csvlens_viewer; @@ -63,7 +67,11 @@ async fn main() -> Result<(), Box> { SqlHighlighter::new(false).unwrap() // Fallback to disabled }); - let helper = ReplHelper::new(highlighter); + // Initialize schema cache for completion + let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); + let completer = SqlCompleter::new(schema_cache.clone(), !context.args.no_completion); + + let helper = ReplHelper::new(highlighter, completer); let mut rl: Editor = Editor::new()?; rl.set_helper(Some(helper)); @@ -88,6 +96,18 @@ async fn main() -> Result<(), Box> { EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())), ); + // Spawn background task to refresh schema cache if completion is enabled + if !context.args.no_completion && is_tty { + let cache = schema_cache.clone(); + let mut ctx_clone = context.clone(); + tokio::spawn(async move { + if let Err(e) = cache.refresh(&mut ctx_clone).await { + // Report errors to help debug + eprintln!("Failed to refresh schema cache: {}", e); + } + }); + } + if is_tty && !context.args.concise { eprintln!("Type \\help for available commands or press Ctrl+V then Enter to view last result. Ctrl+D to exit."); } @@ -132,16 +152,38 @@ async fn main() -> Result<(), Box> { eprintln!("Failed to open viewer: {}", e); } continue; + } else if trimmed == "\\refresh" || trimmed == "\\refresh_cache" { + // Refresh schema cache for auto-completion + if !context.args.no_completion { + let cache = schema_cache.clone(); + let mut ctx_clone = context.clone(); + if is_tty { + eprintln!("Refreshing schema cache..."); + } + tokio::spawn(async move { + if let Err(e) = cache.refresh(&mut ctx_clone).await { + eprintln!("Failed to refresh schema cache: {}", e); + } else { + eprintln!("Schema cache refreshed successfully"); + } + }); + } else { + eprintln!("Auto-completion is disabled. Enable it with: set completion = on;"); + } + continue; } else if trimmed == "\\help" { // Show help for special commands eprintln!("Special commands:"); eprintln!(" \\view - Open last query result in csvlens viewer"); eprintln!(" (requires client format: client:auto, client:vertical, or client:horizontal)"); + eprintln!(" \\refresh - Manually refresh schema cache for auto-completion"); eprintln!(" \\help - Show this help message"); eprintln!(); eprintln!("SQL-style commands:"); - eprintln!(" set format = ; - Change output format"); - eprintln!(" unset format; - Reset format to default"); + eprintln!(" set format = ; - Change output format"); + eprintln!(" unset format; - Reset format to default"); + eprintln!(" set completion = on/off; - Enable/disable auto-completion"); + eprintln!(" unset completion; - Reset completion to default"); eprintln!(); eprintln!("Format values:"); eprintln!(" Client-side rendering (prefix with 'client:'):"); @@ -197,6 +239,12 @@ async fn main() -> Result<(), Box> { } } + // Update completer enabled state after processing queries + // (in case set completion = on/off was executed) + if let Some(helper) = rl.helper_mut() { + helper.completer_mut().set_enabled(!context.args.no_completion); + } + buffer.clear(); } } diff --git a/src/query.rs b/src/query.rs index e40bc66..bc1521b 100644 --- a/src/query.rs +++ b/src/query.rs @@ -105,6 +105,17 @@ pub fn set_args(context: &mut Context, query: &str) -> Result = vec![]; buf.push(format!("{key}={value}")); @@ -133,6 +144,9 @@ pub fn unset_args(context: &mut Context, query: &str) -> Result Result Result> { + let request = reqwest::Client::builder() + .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) + .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) + .http2_keep_alive_while_idle(false) + .tcp_keepalive(Some(std::time::Duration::from_secs(60))) + .build()? + .post(context.url.clone()) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) + .body(query_text.to_string()); + + let request = if let Some(sa_token) = &context.sa_token { + request.header("authorization", format!("Bearer {}", sa_token.token)) + } else if !context.args.jwt.is_empty() { + request.header("authorization", format!("Bearer {}", context.args.jwt)) + } else { + request + }; + + let response = request.send().await?; + let body = response.text().await?; + Ok(body) +} + // Send query and print result. pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box> { // Handle set/unset commands diff --git a/src/repl_helper.rs b/src/repl_helper.rs index 7c48bbb..a6e2c55 100644 --- a/src/repl_helper.rs +++ b/src/repl_helper.rs @@ -1,29 +1,48 @@ +use crate::completion::SqlCompleter; use crate::highlight::SqlHighlighter; -use rustyline::completion::Completer; +use rustyline::completion::{Completer, Pair}; use rustyline::highlight::Highlighter; use rustyline::hint::Hinter; use rustyline::validate::Validator; -use rustyline::Helper; +use rustyline::{Context, Helper}; use std::borrow::Cow; -/// REPL helper that integrates SqlHighlighter with rustyline +/// REPL helper that integrates SqlHighlighter and SqlCompleter with rustyline pub struct ReplHelper { highlighter: SqlHighlighter, + completer: SqlCompleter, } impl ReplHelper { - /// Create a new REPL helper with the given highlighter - pub fn new(highlighter: SqlHighlighter) -> Self { - Self { highlighter } + /// Create a new REPL helper with the given highlighter and completer + pub fn new(highlighter: SqlHighlighter, completer: SqlCompleter) -> Self { + Self { + highlighter, + completer, + } + } + + /// Get a mutable reference to the completer + pub fn completer_mut(&mut self) -> &mut SqlCompleter { + &mut self.completer } } // Implement the Helper trait (required) impl Helper for ReplHelper {} -// Implement empty Completer (no autocompletion for now) +// Delegate completion to SqlCompleter impl Completer for ReplHelper { - type Candidate = String; + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + self.completer.complete(line, pos, ctx) + } } // Implement empty Hinter (no hints for now) From 8f5b3f0fb9cf3418af4f6aa3e41c8f56b72a0a63 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sat, 7 Feb 2026 18:44:48 +0100 Subject: [PATCH 025/147] Add intelligent prioritization to SQL auto-completion Implements context-aware, frequency-based suggestion ordering with schema exploration support. Tables and columns now appear based on query context, usage frequency, and relevance, with system schemas appropriately deprioritized. Schema names can be completed directly and typing 'schema.' shows all tables in that schema. Co-Authored-By: Claude Sonnet 4.5 --- src/completion/context_analyzer.rs | 168 ++++++++++++++++ src/completion/mod.rs | 187 ++++++++++++++++-- src/completion/priority_scorer.rs | 267 +++++++++++++++++++++++++ src/completion/schema_cache.rs | 134 ++++++++++++- src/completion/usage_tracker.rs | 304 +++++++++++++++++++++++++++++ src/context.rs | 4 + src/main.rs | 9 +- src/query.rs | 7 + 8 files changed, 1054 insertions(+), 26 deletions(-) create mode 100644 src/completion/context_analyzer.rs create mode 100644 src/completion/priority_scorer.rs create mode 100644 src/completion/usage_tracker.rs diff --git a/src/completion/context_analyzer.rs b/src/completion/context_analyzer.rs new file mode 100644 index 0000000..16f4946 --- /dev/null +++ b/src/completion/context_analyzer.rs @@ -0,0 +1,168 @@ +/// Analyzes SQL context to extract table names and identify system schemas +pub struct ContextAnalyzer; + +impl ContextAnalyzer { + /// Extract table names from the current SQL statement + /// Looks for patterns like "FROM table", "JOIN table", "UPDATE table" + pub fn extract_tables(sql: &str) -> Vec { + let mut tables = Vec::new(); + let sql_upper = sql.to_uppercase(); + + // Pattern: FROM table_name + if let Some(from_pos) = sql_upper.find("FROM") { + let after_from = &sql[from_pos + 4..]; + if let Some(word) = Self::extract_first_identifier(after_from) { + tables.push(word); + } + } + + // Pattern: JOIN table_name + for (idx, _) in sql_upper.match_indices("JOIN") { + let after_join = &sql[idx + 4..]; + if let Some(word) = Self::extract_first_identifier(after_join) { + tables.push(word); + } + } + + // Pattern: UPDATE table_name + if let Some(update_pos) = sql_upper.find("UPDATE") { + let after_update = &sql[update_pos + 6..]; + if let Some(word) = Self::extract_first_identifier(after_update) { + tables.push(word); + } + } + + // Pattern: INTO table_name (for INSERT statements) + if let Some(into_pos) = sql_upper.find("INTO") { + let after_into = &sql[into_pos + 4..]; + if let Some(word) = Self::extract_first_identifier(after_into) { + tables.push(word); + } + } + + tables + } + + /// Check if a qualified name belongs to a system schema + pub fn is_system_schema(qualified_name: &str) -> bool { + qualified_name.starts_with("information_schema.") + || qualified_name.starts_with("pg_catalog.") + || qualified_name == "information_schema" + || qualified_name == "pg_catalog" + } + + /// Extract the first SQL identifier from text + /// Handles schema-qualified names (e.g., "public.users") + fn extract_first_identifier(text: &str) -> Option { + let trimmed = text.trim(); + let mut identifier = String::new(); + + for ch in trimmed.chars() { + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + identifier.push(ch); + } else if !identifier.is_empty() { + // Stop at first non-identifier character + break; + } + } + + if identifier.is_empty() { + None + } else { + Some(identifier) + } + } + + /// Extract the table name from a qualified column reference + /// E.g., "users.user_id" -> Some("users") + pub fn extract_table_from_qualified_column(qualified_column: &str) -> Option { + if let Some(dot_pos) = qualified_column.rfind('.') { + Some(qualified_column[..dot_pos].to_string()) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_tables_from_simple_query() { + let tables = ContextAnalyzer::extract_tables("SELECT * FROM users"); + assert_eq!(tables, vec!["users"]); + } + + #[test] + fn test_extract_tables_from_join() { + let tables = ContextAnalyzer::extract_tables( + "SELECT * FROM users JOIN orders ON users.id = orders.user_id" + ); + assert_eq!(tables.len(), 2); + assert!(tables.contains(&"users".to_string())); + assert!(tables.contains(&"orders".to_string())); + } + + #[test] + fn test_extract_tables_with_schema() { + let tables = ContextAnalyzer::extract_tables("SELECT * FROM public.users"); + assert_eq!(tables, vec!["public.users"]); + } + + #[test] + fn test_extract_tables_from_update() { + let tables = ContextAnalyzer::extract_tables("UPDATE users SET active = true"); + assert_eq!(tables, vec!["users"]); + } + + #[test] + fn test_extract_tables_from_insert() { + let tables = ContextAnalyzer::extract_tables("INSERT INTO users (name) VALUES ('Alice')"); + assert_eq!(tables, vec!["users"]); + } + + #[test] + fn test_is_system_schema() { + assert!(ContextAnalyzer::is_system_schema("information_schema.tables")); + assert!(ContextAnalyzer::is_system_schema("pg_catalog.pg_class")); + assert!(ContextAnalyzer::is_system_schema("information_schema")); + assert!(ContextAnalyzer::is_system_schema("pg_catalog")); + + assert!(!ContextAnalyzer::is_system_schema("public.users")); + assert!(!ContextAnalyzer::is_system_schema("users")); + assert!(!ContextAnalyzer::is_system_schema("my_schema.table")); + } + + #[test] + fn test_extract_table_from_qualified_column() { + assert_eq!( + ContextAnalyzer::extract_table_from_qualified_column("users.user_id"), + Some("users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_table_from_qualified_column("public.users.user_id"), + Some("public.users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_table_from_qualified_column("user_id"), + None + ); + } + + #[test] + fn test_extract_first_identifier() { + assert_eq!( + ContextAnalyzer::extract_first_identifier(" users "), + Some("users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_first_identifier("public.users WHERE"), + Some("public.users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_first_identifier("users,"), + Some("users".to_string()) + ); + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 125c751..50b1db5 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -1,20 +1,34 @@ +pub mod context_analyzer; pub mod context_detector; +pub mod priority_scorer; pub mod schema_cache; +pub mod usage_tracker; -use context_detector::{detect_context, find_word_start}; +use context_analyzer::ContextAnalyzer; +use context_detector::find_word_start; +use priority_scorer::{PriorityScorer, ScoredSuggestion}; use rustyline::completion::{Completer, Pair}; use rustyline::Context as RustylineContext; use schema_cache::SchemaCache; +use usage_tracker::{ItemType, UsageTracker}; use std::sync::Arc; pub struct SqlCompleter { cache: Arc, + usage_tracker: Arc, + scorer: PriorityScorer, enabled: bool, } impl SqlCompleter { - pub fn new(cache: Arc, enabled: bool) -> Self { - Self { cache, enabled } + pub fn new(cache: Arc, usage_tracker: Arc, enabled: bool) -> Self { + let scorer = PriorityScorer::new(usage_tracker.clone()); + Self { + cache, + usage_tracker, + scorer, + enabled, + } } pub fn set_enabled(&mut self, enabled: bool) { @@ -47,21 +61,162 @@ impl Completer for SqlCompleter { let word_start = find_word_start(line, pos); let partial = &line[word_start..pos]; - // Return only tables and columns (no keywords) - let mut candidates = Vec::new(); + // Extract context: tables mentioned in current statement + let tables_in_line = ContextAnalyzer::extract_tables(line); + + // Generate scored suggestions + let mut scored: Vec = Vec::new(); + + // Check if user is typing "schema_name." to see tables from that schema + let (schema_filter, table_prefix) = if let Some(dot_pos) = partial.rfind('.') { + // User typed something like "information_schema." or "information_schema.engine" + let schema_part = &partial[..dot_pos]; + let table_part = &partial[dot_pos + 1..]; + (Some(schema_part), table_part) + } else { + (None, partial) + }; + + // If user specified a schema (e.g., "information_schema."), show tables from that schema + if let Some(schema_name) = schema_filter { + let tables_in_schema = self.cache.get_tables_in_schema(schema_name, table_prefix); + + for table in tables_in_schema { + let qualified_name = format!("{}.{}", schema_name, table); + let score = self.scorer.score(&table, ItemType::Table, &tables_in_line, None); + + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Table, + score, + }); + } + } else { + // Normal completion: get tables and schemas + let table_metadata = self.cache.get_tables_with_schema(partial); + let column_metadata = self.cache.get_columns_with_table(partial); + let schemas = self.cache.get_schemas(partial); + + // Add schema suggestions (with trailing dot) + let partial_lower = partial.to_lowercase(); + + // Check if partial matches any schema name (without dot) + let partial_matches_schema = !partial.contains('.') && + schemas.iter().any(|s| s.to_lowercase().starts_with(&partial_lower)); + + for schema in &schemas { + let schema_with_dot = format!("{}.", schema); + + // Only add if it matches the partial + if schema_with_dot.to_lowercase().starts_with(&partial_lower) { + // Give schemas fixed priority class 4000 (same as tables) + // Don't use scorer to avoid system schema deprioritization + let base_score = 4000u32; + + // Add small usage bonus if schema has been used + let usage_count = self.usage_tracker.get_count(ItemType::Table, &schema); + let usage_bonus = usage_count.min(99) * 10; + + let score = base_score + usage_bonus; + + scored.push(ScoredSuggestion { + name: schema_with_dot, + item_type: ItemType::Table, + score, + }); + } + } - // Add table names - candidates.extend(self.cache.get_tables(partial)); + // Add table suggestions (both short and qualified names) + for (schema, table) in table_metadata { + let short_name = table.clone(); + let qualified_name = format!("{}.{}", schema, table); - // Add column names - candidates.extend(self.cache.get_columns(partial)); + // Score the short name + let score = self.scorer.score(&short_name, ItemType::Table, &tables_in_line, None); + + // Only add short name if it matches the partial + if short_name.to_lowercase().starts_with(&partial_lower) { + scored.push(ScoredSuggestion { + name: short_name.clone(), + item_type: ItemType::Table, + score, + }); + } + + // Add qualified name only if: + // 1. It matches the partial + // 2. Schema is not "public" or user typed a dot + // 3. Partial doesn't match a schema name (to avoid "infor" -> "information_schema.table") + if qualified_name.to_lowercase().starts_with(&partial_lower) && + (schema != "public" || partial.contains('.')) && + !partial_matches_schema { + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Table, + score, + }); + } + } + + // Add column suggestions (both short and table-qualified names) + for (table, column) in column_metadata { + let short_name = column.clone(); + + // If user typed a dot, check if they're qualifying a table name + let is_qualifying_table = partial.contains('.') && + table.as_ref().map_or(false, |t| partial.to_lowercase().starts_with(&format!("{}.", t.to_lowercase()))); + + // Score the short name + let score = self.scorer.score( + &short_name, + ItemType::Column, + &tables_in_line, + table.as_deref(), + ); + + // Only add short name if it matches the partial and user is NOT qualifying a table + if !is_qualifying_table && short_name.to_lowercase().starts_with(&partial_lower) { + scored.push(ScoredSuggestion { + name: short_name.clone(), + item_type: ItemType::Column, + score, + }); + } + + // Add qualified name (table.column) if we know the table + if let Some(tbl) = table { + let qualified_name = format!("{}.{}", tbl, column); + + // Add qualified name if it matches partial and (no table context yet or user typed a dot) + if qualified_name.to_lowercase().starts_with(&partial_lower) && + (tables_in_line.is_empty() || partial.contains('.')) { + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Column, + score: score.saturating_sub(1), + }); + } + } + } + } // end of else block + + // Sort by score (descending - higher scores first) + scored.sort_by(|a, b| b.score.cmp(&a.score)); + + // Remove duplicates (keep first occurrence, which has highest score) + let mut seen = std::collections::HashSet::new(); + let deduplicated: Vec = scored + .into_iter() + .filter(|s| seen.insert(s.name.clone())) + .collect(); // Convert to Pair format (display, replacement) - let pairs: Vec = candidates + let pairs: Vec = deduplicated .into_iter() - .map(|c| Pair { - display: c.clone(), - replacement: c, + .map(|s| Pair { + display: s.name.clone(), + replacement: s.name, }) .collect(); @@ -77,7 +232,8 @@ mod tests { #[test] fn test_completer_disabled() { let cache = Arc::new(SchemaCache::new(300)); - let completer = SqlCompleter::new(cache, false); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, false); let history = DefaultHistory::new(); let ctx = RustylineContext::new(&history); @@ -89,7 +245,8 @@ mod tests { #[test] fn test_completer_no_keywords() { let cache = Arc::new(SchemaCache::new(300)); - let completer = SqlCompleter::new(cache, true); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); let history = DefaultHistory::new(); let ctx = RustylineContext::new(&history); diff --git a/src/completion/priority_scorer.rs b/src/completion/priority_scorer.rs new file mode 100644 index 0000000..512ff3d --- /dev/null +++ b/src/completion/priority_scorer.rs @@ -0,0 +1,267 @@ +use super::context_analyzer::ContextAnalyzer; +use super::usage_tracker::{ItemType, UsageTracker}; +use std::sync::Arc; + +/// Calculates priority scores for auto-completion suggestions +pub struct PriorityScorer { + usage_tracker: Arc, +} + +/// A suggestion with its calculated priority score +#[derive(Debug, Clone)] +pub struct ScoredSuggestion { + pub name: String, + pub item_type: ItemType, + pub score: u32, +} + +/// Priority classes determine the base score before usage bonuses +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PriorityClass { + /// Columns from tables mentioned in current query - highest priority + ColumnFromQueryTable = 5000, + + /// Tables - high priority when no table context + Table = 4000, + + /// Qualified columns from tables NOT in query + QualifiedColumnOtherTable = 3000, + + /// Unqualified columns from tables NOT in query + UnqualifiedColumnOtherTable = 2000, + + /// System schema items - lowest priority + SystemSchema = 0, +} + +impl PriorityScorer { + pub fn new(usage_tracker: Arc) -> Self { + Self { usage_tracker } + } + + /// Calculate priority score for a suggestion + /// + /// # Arguments + /// * `name` - The suggestion name (e.g., "users", "user_id", "users.user_id") + /// * `item_type` - Whether this is a table or column + /// * `tables_in_statement` - Tables mentioned in the current SQL statement + /// * `column_table` - For columns, the table they belong to (if known) + /// + /// # Returns + /// Priority score (higher = more relevant) + pub fn score( + &self, + name: &str, + item_type: ItemType, + tables_in_statement: &[String], + column_table: Option<&str>, + ) -> u32 { + // Get usage count + let usage_count = self.usage_tracker.get_count(item_type, name); + + // Calculate base score from priority class + let base_score = self.calculate_priority_class( + name, + item_type, + tables_in_statement, + column_table, + ) as u32; + + // Add usage bonus (max 99 uses = 990 points, ensures higher class always wins) + let usage_bonus = usage_count.min(99) * 10; + + base_score + usage_bonus + } + + /// Determine the priority class for an item + fn calculate_priority_class( + &self, + name: &str, + item_type: ItemType, + tables_in_statement: &[String], + column_table: Option<&str>, + ) -> PriorityClass { + // System schemas always get lowest priority + if ContextAnalyzer::is_system_schema(name) { + return PriorityClass::SystemSchema; + } + + match item_type { + ItemType::Table => PriorityClass::Table, + + ItemType::Column => { + // Check if column belongs to a table in the statement + if let Some(table) = column_table { + // Normalize table name for comparison + let normalized_table = table.to_lowercase(); + + // Check if this table is mentioned in the statement + let table_in_statement = tables_in_statement.iter().any(|t| { + let normalized_t = t.to_lowercase(); + normalized_t == normalized_table + || normalized_t.ends_with(&format!(".{}", normalized_table)) + || normalized_table.ends_with(&format!(".{}", normalized_t)) + }); + + if table_in_statement { + // Column from table in query - highest priority + return PriorityClass::ColumnFromQueryTable; + } + } + + // Column from other table - check if qualified + let is_qualified = name.contains('.'); + + if is_qualified { + PriorityClass::QualifiedColumnOtherTable + } else { + PriorityClass::UnqualifiedColumnOtherTable + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_scorer() -> PriorityScorer { + let tracker = Arc::new(UsageTracker::new(10)); + PriorityScorer::new(tracker) + } + + #[test] + fn test_tables_prioritized_when_no_context() { + let scorer = create_test_scorer(); + + let table_score = scorer.score("users", ItemType::Table, &[], None); + let column_score = scorer.score("user_id", ItemType::Column, &[], None); + + assert!(table_score > column_score, "Tables should be prioritized over columns when no context"); + assert!(table_score >= 4000, "Table should be in Table priority class"); + assert!(column_score < 3000, "Column should be in lower priority class"); + } + + #[test] + fn test_columns_prioritized_when_table_in_statement() { + let scorer = create_test_scorer(); + + let column_score = scorer.score( + "user_id", + ItemType::Column, + &["users".to_string()], + Some("users"), + ); + + assert!(column_score >= 5000, "Column from query table should be highest priority"); + } + + #[test] + fn test_system_schema_deprioritized() { + let scorer = create_test_scorer(); + + let user_table_score = scorer.score("users", ItemType::Table, &[], None); + let system_table_score = scorer.score("information_schema.tables", ItemType::Table, &[], None); + + assert!(user_table_score > system_table_score, "User tables should be prioritized over system schemas"); + assert!(system_table_score < 1000, "System schema should be in lowest priority class"); + } + + #[test] + fn test_qualified_vs_unqualified_columns() { + let scorer = create_test_scorer(); + + let qualified_score = scorer.score( + "orders.order_id", + ItemType::Column, + &["users".to_string()], // Different table in statement + Some("orders"), + ); + + let unqualified_score = scorer.score( + "order_id", + ItemType::Column, + &["users".to_string()], + Some("orders"), + ); + + assert!(qualified_score > unqualified_score, "Qualified columns should rank above unqualified"); + assert!(qualified_score >= 3000 && qualified_score < 4000, "Qualified column should be in correct class"); + assert!(unqualified_score >= 2000 && unqualified_score < 3000, "Unqualified column should be in correct class"); + } + + #[test] + fn test_usage_bonus_within_class() { + let tracker = Arc::new(UsageTracker::new(10)); + + // Simulate usage + for _ in 0..50 { + tracker.track_query("SELECT * FROM users"); + } + for _ in 0..10 { + tracker.track_query("SELECT * FROM orders"); + } + + let scorer = PriorityScorer::new(tracker); + + let users_score = scorer.score("users", ItemType::Table, &[], None); + let orders_score = scorer.score("orders", ItemType::Table, &[], None); + + assert!(users_score > orders_score, "More frequently used item should score higher"); + + // Both should be in same priority class (4000-4999) + assert!(users_score >= 4000 && users_score < 5000); + assert!(orders_score >= 4000 && orders_score < 5000); + } + + #[test] + fn test_priority_class_beats_usage_count() { + let tracker = Arc::new(UsageTracker::new(10)); + + // Give column very high usage + for _ in 0..99 { + tracker.track_query("SELECT order_id FROM orders"); + } + + let scorer = PriorityScorer::new(tracker.clone()); + + // Column with high usage from other table + let column_score = scorer.score( + "order_id", + ItemType::Column, + &["users".to_string()], // Different table + Some("orders"), + ); + + // Table with no usage + let table_score = scorer.score("users", ItemType::Table, &[], None); + + // Table should still win because higher priority class + assert!(table_score > column_score, "Higher priority class should beat usage count"); + } + + #[test] + fn test_schema_qualified_table_matching() { + let scorer = create_test_scorer(); + + // Test that schema-qualified names are handled correctly + let score1 = scorer.score( + "user_id", + ItemType::Column, + &["public.users".to_string()], + Some("users"), + ); + + let score2 = scorer.score( + "user_id", + ItemType::Column, + &["users".to_string()], + Some("public.users"), + ); + + // Both should recognize the table match + assert!(score1 >= 5000, "Should match schema.table to table"); + assert!(score2 >= 5000, "Should match table to schema.table"); + } +} diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 8dd42c1..79362c1 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -124,6 +124,126 @@ impl SchemaCache { .collect() } + /// Get all unique schema names matching prefix + pub fn get_schemas(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut schemas = std::collections::HashSet::new(); + for table in tables.values() { + if !table.schema_name.is_empty() { + schemas.insert(table.schema_name.clone()); + } + } + + schemas + .into_iter() + .filter(|schema| schema.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + + /// Get all tables from a specific schema, optionally filtered by table name prefix + pub fn get_tables_in_schema(&self, schema: &str, table_prefix: &str) -> Vec { + let schema_lower = schema.to_lowercase(); + let prefix_lower = table_prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut result: Vec = tables + .values() + .filter(|t| t.schema_name.to_lowercase() == schema_lower) + .filter(|t| { + if prefix_lower.is_empty() { + true + } else { + t.table_name.to_lowercase().starts_with(&prefix_lower) + } + }) + .map(|t| t.table_name.clone()) + .collect(); + + result.sort(); + result + } + + /// Get all table names with their schemas matching prefix + /// Returns Vec<(schema, table)> + pub fn get_tables_with_schema(&self, prefix: &str) -> Vec<(String, String)> { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut result: Vec<(String, String)> = tables + .values() + .filter_map(|t| { + let short_name = t.table_name.to_lowercase(); + let qualified_name = format!("{}.{}", t.schema_name, t.table_name).to_lowercase(); + + // Match either short name or qualified name + if short_name.starts_with(&prefix_lower) || qualified_name.starts_with(&prefix_lower) { + Some((t.schema_name.clone(), t.table_name.clone())) + } else { + None + } + }) + .collect(); + + // Sort by table name for consistent ordering + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Get all column names with their table names matching prefix + /// Returns Vec<(Option, column)> + pub fn get_columns_with_table(&self, prefix: &str) -> Vec<(Option, String)> { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut result: Vec<(Option, String)> = Vec::new(); + let mut seen_columns = HashSet::new(); + + for table in tables.values() { + for column in &table.columns { + let column_name = column.name.to_lowercase(); + let qualified_name = format!("{}.{}", table.table_name, column.name).to_lowercase(); + + // Check if matches prefix (either column name or qualified name) + if column_name.starts_with(&prefix_lower) || qualified_name.starts_with(&prefix_lower) { + // Track unique columns to avoid duplicates + let key = format!("{}:{}", table.table_name, column.name); + if !seen_columns.contains(&key) { + result.push((Some(table.table_name.clone()), column.name.clone())); + seen_columns.insert(key); + } + } + } + } + + // Sort by column name for consistent ordering + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Get the table for a given column name + /// Returns the first table that contains this column + pub fn get_table_for_column(&self, column_name: &str) -> Option { + let tables = self.tables.read().unwrap(); + let column_lower = column_name.to_lowercase(); + + for table in tables.values() { + for column in &table.columns { + if column.name.to_lowercase() == column_lower { + // Return qualified table name + return Some(if table.schema_name == "public" || table.schema_name.is_empty() { + table.table_name.clone() + } else { + format!("{}.{}", table.schema_name, table.table_name) + }); + } + } + } + + None + } + /// Synchronous method to get completions from cache pub fn get_completions( &self, @@ -229,18 +349,16 @@ impl SchemaCache { } async fn do_refresh(&self, context: &mut Context) -> Result<(), Box> { - // Query tables + // Query tables (including system schemas - they'll be deprioritized by the scorer) let tables_query = "SELECT table_schema, table_name \ FROM information_schema.tables \ - WHERE table_schema NOT IN ('information_schema', 'pg_catalog') \ ORDER BY table_schema, table_name"; let tables_result = query_silent(context, tables_query).await; - // Query columns + // Query columns (including system schemas - they'll be deprioritized by the scorer) let columns_query = "SELECT table_schema, table_name, column_name, data_type \ FROM information_schema.columns \ - WHERE table_schema NOT IN ('information_schema', 'pg_catalog') \ ORDER BY table_schema, table_name, ordinal_position"; let columns_result = query_silent(context, columns_query).await; @@ -324,10 +442,6 @@ impl SchemaCache { } } - // Debug output - eprintln!("Schema cache loaded: {} tables, {} columns, {} functions", - num_tables, num_columns, num_functions); - Ok(()) } @@ -474,7 +588,9 @@ impl SchemaCache { } } - if result.is_empty() { + // Always return Some - empty result is valid (no functions defined) + // Only return None if output is completely empty/unparseable + if output.trim().is_empty() { None } else { Some(result) diff --git a/src/completion/usage_tracker.rs b/src/completion/usage_tracker.rs new file mode 100644 index 0000000..533c52f --- /dev/null +++ b/src/completion/usage_tracker.rs @@ -0,0 +1,304 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemType { + Table, + Column, +} + +/// Tracks usage frequency of tables and columns to enable intelligent prioritization +pub struct UsageTracker { + // Item name -> usage count + table_counts: Arc>>, + column_counts: Arc>>, + + // Recent queries (ring buffer of last N) + recent_queries: Arc>>, + max_recent: usize, +} + +impl UsageTracker { + /// Create a new UsageTracker with a maximum number of recent queries to track + pub fn new(max_recent: usize) -> Self { + Self { + table_counts: Arc::new(RwLock::new(HashMap::new())), + column_counts: Arc::new(RwLock::new(HashMap::new())), + recent_queries: Arc::new(RwLock::new(Vec::new())), + max_recent, + } + } + + /// Track a query execution by extracting table and column names + pub fn track_query(&self, query: &str) { + // 1. Add to recent queries (ring buffer) + { + let mut recent = self.recent_queries.write().unwrap(); + recent.push(query.to_string()); + if recent.len() > self.max_recent { + recent.remove(0); + } + } + + // 2. Extract table and column names + let tables = Self::extract_table_names(query); + let columns = Self::extract_column_names(query); + + // 3. Increment usage counts + { + let mut table_counts = self.table_counts.write().unwrap(); + for table in tables { + *table_counts.entry(table).or_insert(0) += 1; + } + } + + { + let mut column_counts = self.column_counts.write().unwrap(); + for column in columns { + *column_counts.entry(column).or_insert(0) += 1; + } + } + } + + /// Get usage count for an item + pub fn get_count(&self, item_type: ItemType, name: &str) -> u32 { + match item_type { + ItemType::Table => { + let counts = self.table_counts.read().unwrap(); + counts.get(name).copied().unwrap_or(0) + } + ItemType::Column => { + let counts = self.column_counts.read().unwrap(); + counts.get(name).copied().unwrap_or(0) + } + } + } + + /// Check if an item name appears in any recent query + pub fn was_used_recently(&self, name: &str) -> bool { + let recent = self.recent_queries.read().unwrap(); + recent.iter().any(|q| q.contains(name)) + } + + /// Extract table names from SQL query using simple pattern matching + fn extract_table_names(query: &str) -> Vec { + let mut tables = Vec::new(); + let query_upper = query.to_uppercase(); + + // Pattern: FROM table_name + if let Some(from_pos) = query_upper.find("FROM") { + let after_from = &query[from_pos + 4..]; + if let Some(word) = Self::extract_first_word(after_from) { + tables.push(word); + } + } + + // Pattern: JOIN table_name + for (idx, _) in query_upper.match_indices("JOIN") { + let after_join = &query[idx + 4..]; + if let Some(word) = Self::extract_first_word(after_join) { + tables.push(word); + } + } + + // Pattern: UPDATE table_name + if let Some(update_pos) = query_upper.find("UPDATE") { + let after_update = &query[update_pos + 6..]; + if let Some(word) = Self::extract_first_word(after_update) { + tables.push(word); + } + } + + // Pattern: INTO table_name + if let Some(into_pos) = query_upper.find("INTO") { + let after_into = &query[into_pos + 4..]; + if let Some(word) = Self::extract_first_word(after_into) { + tables.push(word); + } + } + + tables + } + + /// Extract column names from SQL query using simple pattern matching + fn extract_column_names(query: &str) -> Vec { + let mut columns = Vec::new(); + let query_upper = query.to_uppercase(); + + // Pattern: SELECT columns FROM + if let Some(select_pos) = query_upper.find("SELECT") { + if let Some(from_pos) = query_upper.find("FROM") { + // Only extract if FROM comes after SELECT + if from_pos > select_pos + 6 { + let between = &query[select_pos + 6..from_pos]; + columns.extend(Self::extract_column_list(between)); + } + } + } + + // Pattern: WHERE column = value + for (idx, _) in query_upper.match_indices("WHERE") { + let after_where = &query[idx + 5..]; + if let Some(word) = Self::extract_first_word(after_where) { + columns.push(word); + } + } + + // Pattern: ORDER BY column + for (idx, _) in query_upper.match_indices("ORDER BY") { + let after_order = &query[idx + 8..]; + if let Some(word) = Self::extract_first_word(after_order) { + columns.push(word); + } + } + + // Pattern: GROUP BY column + for (idx, _) in query_upper.match_indices("GROUP BY") { + let after_group = &query[idx + 8..]; + if let Some(word) = Self::extract_first_word(after_group) { + columns.push(word); + } + } + + columns + } + + /// Extract first identifier from text (handles schema.table notation) + fn extract_first_word(text: &str) -> Option { + let trimmed = text.trim(); + let mut word = String::new(); + + for ch in trimmed.chars() { + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + word.push(ch); + } else if !word.is_empty() { + break; + } + } + + if word.is_empty() { + None + } else { + Some(word) + } + } + + /// Extract comma-separated column list + fn extract_column_list(text: &str) -> Vec { + let mut columns = Vec::new(); + + for part in text.split(',') { + let trimmed = part.trim(); + + // Skip * and common SQL functions/keywords + if trimmed == "*" || trimmed.is_empty() { + continue; + } + + // Extract just the column name (before AS, spaces, etc.) + if let Some(word) = Self::extract_first_word(trimmed) { + // Remove table prefix if present (e.g., "users.id" -> "id") + let column_name = if let Some(dot_pos) = word.rfind('.') { + word[dot_pos + 1..].to_string() + } else { + word + }; + + // Filter out SQL keywords + let upper = column_name.to_uppercase(); + if !["AS", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "EXISTS"].contains(&upper.as_str()) { + columns.push(column_name); + } + } + } + + columns + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_track_query_increments_table_counts() { + let tracker = UsageTracker::new(10); + tracker.track_query("SELECT * FROM users"); + assert_eq!(tracker.get_count(ItemType::Table, "users"), 1); + + tracker.track_query("SELECT * FROM users"); + assert_eq!(tracker.get_count(ItemType::Table, "users"), 2); + } + + #[test] + fn test_track_query_increments_column_counts() { + let tracker = UsageTracker::new(10); + tracker.track_query("SELECT user_id, username FROM users"); + assert_eq!(tracker.get_count(ItemType::Column, "user_id"), 1); + assert_eq!(tracker.get_count(ItemType::Column, "username"), 1); + } + + #[test] + fn test_recent_queries_ring_buffer() { + let tracker = UsageTracker::new(2); + tracker.track_query("query1"); + tracker.track_query("query2"); + tracker.track_query("query3"); // Should evict query1 + + assert!(!tracker.was_used_recently("query1")); + assert!(tracker.was_used_recently("query2")); + assert!(tracker.was_used_recently("query3")); + } + + #[test] + fn test_extract_table_names_from_query() { + let tables = UsageTracker::extract_table_names( + "SELECT * FROM users JOIN orders ON users.id = orders.user_id" + ); + assert!(tables.contains(&"users".to_string())); + assert!(tables.contains(&"orders".to_string())); + } + + #[test] + fn test_extract_table_names_with_schema() { + let tables = UsageTracker::extract_table_names("SELECT * FROM public.users"); + assert_eq!(tables, vec!["public.users"]); + } + + #[test] + fn test_extract_column_names() { + let columns = UsageTracker::extract_column_names( + "SELECT user_id, username FROM users WHERE active = true" + ); + assert!(columns.contains(&"user_id".to_string())); + assert!(columns.contains(&"username".to_string())); + assert!(columns.contains(&"active".to_string())); + } + + #[test] + fn test_was_used_recently() { + let tracker = UsageTracker::new(10); + tracker.track_query("SELECT * FROM users"); + assert!(tracker.was_used_recently("users")); + assert!(!tracker.was_used_recently("orders")); + } + + #[test] + fn test_extract_column_names_with_keywords_out_of_order() { + // Regression test for panic when FROM appears before SELECT + let columns = UsageTracker::extract_column_names("from test select val"); + // Should not panic, and should not extract columns since FROM comes before SELECT + assert_eq!(columns.len(), 0); + } + + #[test] + fn test_extract_column_names_edge_cases() { + // Test with only SELECT, no FROM + let columns = UsageTracker::extract_column_names("SELECT user_id"); + assert_eq!(columns.len(), 0); // No FROM, so no columns extracted + + // Test with FROM immediately after SELECT (no space for columns) + let columns = UsageTracker::extract_column_names("SELECTFROM users"); + assert_eq!(columns.len(), 0); + } +} diff --git a/src/context.rs b/src/context.rs index 1d6fced..e1e533d 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,6 +1,8 @@ use crate::args::{get_url, Args}; +use crate::completion::usage_tracker::UsageTracker; use crate::table_renderer::ParsedResult; use serde::{Deserialize, Serialize}; +use std::sync::Arc; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ServiceAccountToken { @@ -21,6 +23,7 @@ pub struct Context { pub last_result: Option, pub last_stats: Option, pub is_interactive: bool, + pub usage_tracker: Option>, } impl Context { @@ -36,6 +39,7 @@ impl Context { last_result: None, last_stats: None, is_interactive: false, + usage_tracker: None, } } diff --git a/src/main.rs b/src/main.rs index e59b916..0154353 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod viewer; use args::get_args; use auth::maybe_authenticate; use completion::schema_cache::SchemaCache; +use completion::usage_tracker::UsageTracker; use completion::SqlCompleter; use context::Context; use highlight::SqlHighlighter; @@ -67,9 +68,13 @@ async fn main() -> Result<(), Box> { SqlHighlighter::new(false).unwrap() // Fallback to disabled }); - // Initialize schema cache for completion + // Initialize schema cache and usage tracker for completion let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); - let completer = SqlCompleter::new(schema_cache.clone(), !context.args.no_completion); + let usage_tracker = Arc::new(UsageTracker::new(10)); // Track last 10 queries + let completer = SqlCompleter::new(schema_cache.clone(), usage_tracker.clone(), !context.args.no_completion); + + // Store usage tracker in context for query tracking + context.usage_tracker = Some(usage_tracker.clone()); let helper = ReplHelper::new(highlighter, completer); let mut rl: Editor = Editor::new()?; diff --git a/src/query.rs b/src/query.rs index bc1521b..3bae7bc 100644 --- a/src/query.rs +++ b/src/query.rs @@ -215,6 +215,9 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let start = Instant::now(); + // Clone query_text for tracking later + let query_text_for_tracking = query_text.clone(); + let mut request = reqwest::Client::builder() .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) @@ -448,6 +451,10 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if query_failed { Err("Query failed".into()) } else { + // Track successful query for auto-completion prioritization + if let Some(usage_tracker) = &context.usage_tracker { + usage_tracker.track_query(&query_text_for_tracking); + } Ok(()) } } From 6a747d9c904dca929dc2cc236165de7e65363984 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sat, 7 Feb 2026 19:36:04 +0100 Subject: [PATCH 026/147] Add function name completion to SQL auto-completion Implements auto-completion for SQL functions with lowest priority (below columns, above system schemas). Functions complete with opening parenthesis for immediate argument typing. Operators are filtered out using routine_type != 'OPERATOR' from information_schema.routines. Co-Authored-By: Claude Sonnet 4.5 --- src/args.rs | 6 ++++++ src/completion/mod.rs | 25 +++++++++++++++++++++++++ src/completion/priority_scorer.rs | 5 +++++ src/completion/schema_cache.rs | 16 ++++++++++++++-- src/completion/usage_tracker.rs | 9 ++++++++- 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/args.rs b/src/args.rs index 4ed9b92..006ca1b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -30,7 +30,13 @@ fn default_max_cell_length() -> usize { 1000 } +fn default_true() -> bool { + true +} +fn default_cache_ttl() -> u64 { + 300 +} #[derive(Clone, Debug, Options, Deserialize, Serialize)] pub struct Args { diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 50b1db5..431110e 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -199,6 +199,31 @@ impl Completer for SqlCompleter { } } } + + // Add function suggestions + let functions = self.cache.get_functions(partial); + for function in functions { + // Only add if it matches the partial + if function.to_lowercase().starts_with(&partial_lower) { + // Give functions fixed priority class 1000 (below columns, above system schemas) + let base_score = 1000u32; + + // Add small usage bonus if function has been used + let usage_count = self.usage_tracker.get_count(ItemType::Function, &function); + let usage_bonus = usage_count.min(99) * 10; + + let score = base_score + usage_bonus; + + // Add opening parenthesis to function names (no closing paren for easier typing) + let function_with_paren = format!("{}(", function); + + scored.push(ScoredSuggestion { + name: function_with_paren, + item_type: ItemType::Column, // Use Column type for now + score, + }); + } + } } // end of else block // Sort by score (descending - higher scores first) diff --git a/src/completion/priority_scorer.rs b/src/completion/priority_scorer.rs index 512ff3d..a3f26f0 100644 --- a/src/completion/priority_scorer.rs +++ b/src/completion/priority_scorer.rs @@ -30,6 +30,9 @@ enum PriorityClass { /// Unqualified columns from tables NOT in query UnqualifiedColumnOtherTable = 2000, + /// Functions - lower priority than columns + Function = 1000, + /// System schema items - lowest priority SystemSchema = 0, } @@ -118,6 +121,8 @@ impl PriorityScorer { PriorityClass::UnqualifiedColumnOtherTable } } + + ItemType::Function => PriorityClass::Function, } } } diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 79362c1..e4ed274 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -142,6 +142,18 @@ impl SchemaCache { .collect() } + /// Get all function names matching prefix + pub fn get_functions(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let functions = self.functions.read().unwrap(); + + functions + .iter() + .filter(|f| f.to_lowercase().starts_with(&prefix_lower)) + .cloned() + .collect() + } + /// Get all tables from a specific schema, optionally filtered by table name prefix pub fn get_tables_in_schema(&self, schema: &str, table_prefix: &str) -> Vec { let schema_lower = schema.to_lowercase(); @@ -363,10 +375,10 @@ impl SchemaCache { let columns_result = query_silent(context, columns_query).await; - // Query functions + // Query functions (including system functions, excluding operators) let functions_query = "SELECT routine_name \ FROM information_schema.routines \ - WHERE routine_schema NOT IN ('information_schema', 'pg_catalog') \ + WHERE routine_type != 'OPERATOR' \ ORDER BY routine_name"; let functions_result = query_silent(context, functions_query).await; diff --git a/src/completion/usage_tracker.rs b/src/completion/usage_tracker.rs index 533c52f..786d895 100644 --- a/src/completion/usage_tracker.rs +++ b/src/completion/usage_tracker.rs @@ -5,13 +5,15 @@ use std::sync::{Arc, RwLock}; pub enum ItemType { Table, Column, + Function, } -/// Tracks usage frequency of tables and columns to enable intelligent prioritization +/// Tracks usage frequency of tables, columns, and functions to enable intelligent prioritization pub struct UsageTracker { // Item name -> usage count table_counts: Arc>>, column_counts: Arc>>, + function_counts: Arc>>, // Recent queries (ring buffer of last N) recent_queries: Arc>>, @@ -24,6 +26,7 @@ impl UsageTracker { Self { table_counts: Arc::new(RwLock::new(HashMap::new())), column_counts: Arc::new(RwLock::new(HashMap::new())), + function_counts: Arc::new(RwLock::new(HashMap::new())), recent_queries: Arc::new(RwLock::new(Vec::new())), max_recent, } @@ -71,6 +74,10 @@ impl UsageTracker { let counts = self.column_counts.read().unwrap(); counts.get(name).copied().unwrap_or(0) } + ItemType::Function => { + let counts = self.function_counts.read().unwrap(); + counts.get(name).copied().unwrap_or(0) + } } } From 424ad00e07e893ec64529b1bca169d6788d61fab Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 16:46:31 +0100 Subject: [PATCH 027/147] Replace rustyline REPL with ratatui TUI (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the interactive REPL using ratatui + tui-textarea, replacing rustyline entirely. The headless (single-query) path is unchanged. Layout: 3-pane vertical split — scrollable output pane (bottom-anchored), multi-line input area with tui-textarea, 1-line status bar. Key features: - Multi-line SQL editing with Shift+Enter for explicit newlines - File-backed history (same ~/.firebolt/fb_history format) with Up/Down and Ctrl+Up/Down navigation, deduplication, 10k cap - Kitty keyboard protocol for Shift+Enter disambiguation - Mouse scroll routed to output pane; Shift+drag still selects text - Ctrl+C cancels input or in-flight query via CancellationToken - Ctrl+V / \view opens csvlens (suspends ratatui, resumes after) - Query output routed through TuiMsg channel (Line / StyledLines) to avoid ANSI round-trips for table rendering - Custom TUI table renderer with Unicode box-drawing borders, smart column-width algorithm, auto horizontal/vertical switching, per-column header (cyan), NULL (dark gray), error (red) styling - Control characters in cell values replaced with spaces to prevent ratatui misalignment (fixes query_text newline rendering bug) - SQL echo in output pane: ❯ green+bold, SQL text yellow - Stats lines (Time:/Scanned:) rendered in dark gray - Spinner + elapsed time shown while query is running New files: src/tui/{mod,layout,output_pane,history}.rs, src/tui_msg.rs Removed: src/repl_helper.rs (rustyline adapter no longer needed) Added: src/docs/{tui,output_pipeline,table_rendering,completion}.md Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 126 +------- Cargo.toml | 6 +- src/completion/mod.rs | 90 ++---- src/context.rs | 73 +++++ src/docs/completion.md | 132 ++++++++ src/docs/output_pipeline.md | 103 +++++++ src/docs/table_rendering.md | 139 +++++++++ src/docs/tui.md | 126 ++++++++ src/highlight.rs | 125 +++----- src/main.rs | 272 +++-------------- src/query.rs | 388 ++++++++++++++++-------- src/repl_helper.rs | 75 ----- src/table_renderer.rs | 425 ++++++++++++++++++++++++++ src/tui/history.rs | 161 ++++++++++ src/tui/layout.rs | 49 +++ src/tui/mod.rs | 585 ++++++++++++++++++++++++++++++++++++ src/tui/output_pane.rs | 250 +++++++++++++++ src/tui_msg.rs | 33 ++ 18 files changed, 2463 insertions(+), 695 deletions(-) create mode 100644 src/docs/completion.md create mode 100644 src/docs/output_pipeline.md create mode 100644 src/docs/table_rendering.md create mode 100644 src/docs/tui.md delete mode 100644 src/repl_helper.rs create mode 100644 src/tui/history.rs create mode 100644 src/tui/layout.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/output_pane.rs create mode 100644 src/tui_msg.rs diff --git a/Cargo.lock b/Cargo.lock index ecc1ddd..c56cd8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,7 +115,7 @@ version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ - "clipboard-win 5.4.1", + "clipboard-win", "log", "objc2", "objc2-app-kit", @@ -446,24 +446,13 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" -[[package]] -name = "clipboard-win" -version = "4.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" -dependencies = [ - "error-code 2.3.1", - "str-buf", - "winapi", -] - [[package]] name = "clipboard-win" version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ - "error-code 3.3.2", + "error-code", ] [[package]] @@ -752,12 +741,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - [[package]] name = "equivalent" version = "1.0.2" @@ -774,16 +757,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" -dependencies = [ - "libc", - "str-buf", -] - [[package]] name = "error-code" version = "3.3.2" @@ -807,6 +780,7 @@ name = "fb" version = "0.2.3" dependencies = [ "comfy-table", + "crossterm 0.28.1", "csvlens", "dirs", "gumdrop", @@ -814,9 +788,9 @@ dependencies = [ "openssl", "pest", "pest_derive", + "ratatui", "regex", "reqwest", - "rustyline", "serde", "serde_json", "serde_yaml", @@ -824,20 +798,10 @@ dependencies = [ "tokio", "tokio-util", "toml", + "tui-textarea", "urlencoding", ] -[[package]] -name = "fd-lock" -version = "3.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" -dependencies = [ - "cfg-if", - "rustix 0.38.44", - "windows-sys 0.48.0", -] - [[package]] name = "filedescriptor" version = "0.8.3" @@ -1060,15 +1024,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.0" @@ -1578,26 +1533,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -2025,16 +1960,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type", - "nibble_vec", -] - [[package]] name = "ratatui" version = "0.29.0" @@ -2224,30 +2149,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rustyline" -version = "12.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "clipboard-win 4.5.0", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "regex", - "scopeguard", - "unicode-segmentation", - "unicode-width 0.1.14", - "utf8parse", - "winapi", -] - [[package]] name = "ryu" version = "1.0.22" @@ -2464,12 +2365,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - [[package]] name = "strsim" version = "0.11.1" @@ -2907,6 +2802,17 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index ec3d17c..acd6a5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,12 @@ license = "Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -rustyline = { version = "12.0.0", features = ["case_insensitive_history_search"] } +ratatui = "0.29" +tui-textarea = "0.7" +crossterm = "0.28" gumdrop = { version = "0.8.1", features = ["default_expr"] } reqwest = { version = "0.12", features = ["json", "http2"] } -openssl = { version = "*", features = ["vendored"] } +openssl = { version = "*", features = ["vendored"] } tokio = { version = "1", features = ["full"] } tokio-util = "0.7.10" dirs = "5.0" diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 431110e..8a15370 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -7,8 +7,6 @@ pub mod usage_tracker; use context_analyzer::ContextAnalyzer; use context_detector::find_word_start; use priority_scorer::{PriorityScorer, ScoredSuggestion}; -use rustyline::completion::{Completer, Pair}; -use rustyline::Context as RustylineContext; use schema_cache::SchemaCache; use usage_tracker::{ItemType, UsageTracker}; use std::sync::Arc; @@ -42,19 +40,13 @@ impl SqlCompleter { pub fn cache(&self) -> &Arc { &self.cache } -} - -impl Completer for SqlCompleter { - type Candidate = Pair; - fn complete( - &self, - line: &str, - pos: usize, - _ctx: &RustylineContext<'_>, - ) -> rustyline::Result<(usize, Vec)> { + /// Return a list of completion candidates for the given input line and cursor position. + /// Returns `(word_start, candidates)` where `word_start` is the byte offset of the + /// start of the word being completed and `candidates` are the replacement strings. + pub fn complete_at(&self, line: &str, pos: usize) -> (usize, Vec) { if !self.enabled { - return Ok((0, Vec::new())); + return (0, Vec::new()); } // Find the start of the word we're completing @@ -69,7 +61,6 @@ impl Completer for SqlCompleter { // Check if user is typing "schema_name." to see tables from that schema let (schema_filter, table_prefix) = if let Some(dot_pos) = partial.rfind('.') { - // User typed something like "information_schema." or "information_schema.engine" let schema_part = &partial[..dot_pos]; let table_part = &partial[dot_pos + 1..]; (Some(schema_part), table_part) @@ -77,7 +68,6 @@ impl Completer for SqlCompleter { (None, partial) }; - // If user specified a schema (e.g., "information_schema."), show tables from that schema if let Some(schema_name) = schema_filter { let tables_in_schema = self.cache.get_tables_in_schema(schema_name, table_prefix); @@ -92,31 +82,22 @@ impl Completer for SqlCompleter { }); } } else { - // Normal completion: get tables and schemas let table_metadata = self.cache.get_tables_with_schema(partial); let column_metadata = self.cache.get_columns_with_table(partial); let schemas = self.cache.get_schemas(partial); - // Add schema suggestions (with trailing dot) let partial_lower = partial.to_lowercase(); - // Check if partial matches any schema name (without dot) let partial_matches_schema = !partial.contains('.') && schemas.iter().any(|s| s.to_lowercase().starts_with(&partial_lower)); for schema in &schemas { let schema_with_dot = format!("{}.", schema); - // Only add if it matches the partial if schema_with_dot.to_lowercase().starts_with(&partial_lower) { - // Give schemas fixed priority class 4000 (same as tables) - // Don't use scorer to avoid system schema deprioritization let base_score = 4000u32; - - // Add small usage bonus if schema has been used - let usage_count = self.usage_tracker.get_count(ItemType::Table, &schema); + let usage_count = self.usage_tracker.get_count(ItemType::Table, schema); let usage_bonus = usage_count.min(99) * 10; - let score = base_score + usage_bonus; scored.push(ScoredSuggestion { @@ -127,15 +108,12 @@ impl Completer for SqlCompleter { } } - // Add table suggestions (both short and qualified names) for (schema, table) in table_metadata { let short_name = table.clone(); let qualified_name = format!("{}.{}", schema, table); - // Score the short name let score = self.scorer.score(&short_name, ItemType::Table, &tables_in_line, None); - // Only add short name if it matches the partial if short_name.to_lowercase().starts_with(&partial_lower) { scored.push(ScoredSuggestion { name: short_name.clone(), @@ -144,10 +122,6 @@ impl Completer for SqlCompleter { }); } - // Add qualified name only if: - // 1. It matches the partial - // 2. Schema is not "public" or user typed a dot - // 3. Partial doesn't match a schema name (to avoid "infor" -> "information_schema.table") if qualified_name.to_lowercase().starts_with(&partial_lower) && (schema != "public" || partial.contains('.')) && !partial_matches_schema { @@ -159,15 +133,12 @@ impl Completer for SqlCompleter { } } - // Add column suggestions (both short and table-qualified names) for (table, column) in column_metadata { let short_name = column.clone(); - // If user typed a dot, check if they're qualifying a table name let is_qualifying_table = partial.contains('.') && table.as_ref().map_or(false, |t| partial.to_lowercase().starts_with(&format!("{}.", t.to_lowercase()))); - // Score the short name let score = self.scorer.score( &short_name, ItemType::Column, @@ -175,7 +146,6 @@ impl Completer for SqlCompleter { table.as_deref(), ); - // Only add short name if it matches the partial and user is NOT qualifying a table if !is_qualifying_table && short_name.to_lowercase().starts_with(&partial_lower) { scored.push(ScoredSuggestion { name: short_name.clone(), @@ -184,11 +154,9 @@ impl Completer for SqlCompleter { }); } - // Add qualified name (table.column) if we know the table if let Some(tbl) = table { let qualified_name = format!("{}.{}", tbl, column); - // Add qualified name if it matches partial and (no table context yet or user typed a dot) if qualified_name.to_lowercase().starts_with(&partial_lower) && (tables_in_line.is_empty() || partial.contains('.')) { scored.push(ScoredSuggestion { @@ -200,59 +168,42 @@ impl Completer for SqlCompleter { } } - // Add function suggestions let functions = self.cache.get_functions(partial); for function in functions { - // Only add if it matches the partial if function.to_lowercase().starts_with(&partial_lower) { - // Give functions fixed priority class 1000 (below columns, above system schemas) let base_score = 1000u32; - - // Add small usage bonus if function has been used let usage_count = self.usage_tracker.get_count(ItemType::Function, &function); let usage_bonus = usage_count.min(99) * 10; - let score = base_score + usage_bonus; - - // Add opening parenthesis to function names (no closing paren for easier typing) let function_with_paren = format!("{}(", function); scored.push(ScoredSuggestion { name: function_with_paren, - item_type: ItemType::Column, // Use Column type for now + item_type: ItemType::Column, score, }); } } - } // end of else block + } - // Sort by score (descending - higher scores first) + // Sort by score descending scored.sort_by(|a, b| b.score.cmp(&a.score)); - // Remove duplicates (keep first occurrence, which has highest score) + // Deduplicate (keep highest-scored occurrence) let mut seen = std::collections::HashSet::new(); - let deduplicated: Vec = scored + let candidates: Vec = scored .into_iter() .filter(|s| seen.insert(s.name.clone())) + .map(|s| s.name) .collect(); - // Convert to Pair format (display, replacement) - let pairs: Vec = deduplicated - .into_iter() - .map(|s| Pair { - display: s.name.clone(), - replacement: s.name, - }) - .collect(); - - Ok((word_start, pairs)) + (word_start, candidates) } } #[cfg(test)] mod tests { use super::*; - use rustyline::history::DefaultHistory; #[test] fn test_completer_disabled() { @@ -260,11 +211,8 @@ mod tests { let usage_tracker = Arc::new(UsageTracker::new(10)); let completer = SqlCompleter::new(cache, usage_tracker, false); - let history = DefaultHistory::new(); - let ctx = RustylineContext::new(&history); - let result = completer.complete("SELECT * FROM us", 17, &ctx).unwrap(); - - assert_eq!(result.1.len(), 0); + let (_, candidates) = completer.complete_at("SELECT * FROM us", 17); + assert_eq!(candidates.len(), 0); } #[test] @@ -273,11 +221,9 @@ mod tests { let usage_tracker = Arc::new(UsageTracker::new(10)); let completer = SqlCompleter::new(cache, usage_tracker, true); - let history = DefaultHistory::new(); - let ctx = RustylineContext::new(&history); - let result = completer.complete("SEL", 3, &ctx).unwrap(); + let (_, candidates) = completer.complete_at("SEL", 3); - // Should not return keywords (only tables and columns) - assert!(!result.1.iter().any(|p| p.display == "SELECT")); + // Should not return keywords (only tables and columns from schema cache) + assert!(!candidates.iter().any(|c| c == "SELECT")); } } diff --git a/src/context.rs b/src/context.rs index e1e533d..d8fdb79 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,8 +1,11 @@ use crate::args::{get_url, Args}; use crate::completion::usage_tracker::UsageTracker; use crate::table_renderer::ParsedResult; +use crate::tui_msg::{TuiLine, TuiMsg}; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use tokio_util::sync::CancellationToken; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ServiceAccountToken { @@ -24,6 +27,14 @@ pub struct Context { pub last_stats: Option, pub is_interactive: bool, pub usage_tracker: Option>, + + /// When running inside the TUI, all output lines are sent here instead of + /// going to stdout/stderr. `None` in headless / non-interactive mode. + pub tui_output_tx: Option>, + + /// When running inside the TUI, this token can be cancelled by the user + /// (Ctrl+C) to abort an in-flight query. Replaces the SIGINT handler. + pub query_cancel: Option, } impl Context { @@ -40,6 +51,8 @@ impl Context { last_stats: None, is_interactive: false, usage_tracker: None, + tui_output_tx: None, + query_cancel: None, } } @@ -58,6 +71,66 @@ impl Context { pub fn set_prompt3(&mut self, prompt: String) { self.prompt3 = Some(prompt); } + + // ── Output helpers ────────────────────────────────────────────────────── + + /// Emit a line of normal output. In TUI mode: sends to the output channel. + /// In headless mode: prints to stdout. + pub fn emit(&self, text: impl AsRef) { + let text = text.as_ref(); + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::Line(text.to_string())); + } else { + println!("{}", text); + } + } + + /// Emit a line of error/diagnostic output. In TUI mode: sends to the + /// output channel (same pane, different style applied by TuiApp). + /// In headless mode: prints to stderr. + pub fn emit_err(&self, text: impl AsRef) { + let text = text.as_ref(); + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::Line(text.to_string())); + } else { + eprintln!("{}", text); + } + } + + /// Emit an empty line (equivalent to `println!()` / `eprintln!()`). + pub fn emit_newline(&self) { + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::Line(String::new())); + } else { + eprintln!(); + } + } + + /// Emit raw multi-line text. In TUI mode each `\n` becomes a new output + /// line. In headless mode it is printed without an extra trailing newline. + pub fn emit_raw(&self, text: impl AsRef) { + let text = text.as_ref(); + if let Some(tx) = &self.tui_output_tx { + for line in text.split('\n') { + let _ = tx.send(TuiMsg::Line(line.to_string())); + } + } else { + print!("{}", text); + } + } + + /// Emit pre-styled lines (TUI only; no-op in headless mode — callers must + /// use a String-based fallback for non-TUI output). + pub fn emit_styled_lines(&self, lines: Vec) { + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::StyledLines(lines)); + } + } + + /// Returns `true` when running inside the TUI event loop. + pub fn is_tui(&self) -> bool { + self.tui_output_tx.is_some() + } } #[cfg(test)] diff --git a/src/docs/completion.md b/src/docs/completion.md new file mode 100644 index 0000000..4950a1c --- /dev/null +++ b/src/docs/completion.md @@ -0,0 +1,132 @@ +# SQL Auto-Completion + +Auto-completion is implemented as a pure in-memory system: no external process, no LSP. It uses a schema cache populated at session start and a priority scorer to surface the most relevant suggestion first. + +## Components + +| File | Struct | Role | +|------|--------|------| +| `completion/mod.rs` | `SqlCompleter` | Orchestrates completion; called by the REPL | +| `completion/schema_cache.rs` | `SchemaCache` | Caches DB metadata; refreshed async | +| `completion/priority_scorer.rs` | `PriorityScorer` | Scores suggestions by class + usage | +| `completion/usage_tracker.rs` | `UsageTracker` | Counts how often each item has been used | +| `completion/context_analyzer.rs` | `ContextAnalyzer` | Extracts table names from the current query | +| `completion/context_detector.rs` | `ContextDetector` | Classifies the cursor position (FROM, SELECT, etc.) | + +## Request flow + +``` +tui-textarea Tab keypress + │ + ▼ +SqlCompleter::complete_at(line, cursor_byte) + │ + ├── ContextDetector::detect() → CompletionContext + ├── ContextAnalyzer::extract_tables() → Vec (tables in query) + ├── SchemaCache::get_*() → candidate lists + └── PriorityScorer::score() → ranked Vec +``` + +The completer returns `(word_start_byte, Vec)`. The TUI replaces `line[word_start..]` up to the cursor with the selected candidate. + +## SchemaCache (`completion/schema_cache.rs`) + +Thread-safe (`Arc>`) cache of: +- Tables: `HashMap` keyed by `"schema.table"` +- Columns: per table, a `Vec` +- Functions: `Vec` +- SQL keywords: a hardcoded list of ~60 SQL keywords + +### Refresh + +`refresh()` is an async method that executes three queries against the connected database: + +```sql +SELECT table_schema, table_name FROM information_schema.tables; +SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns; +SELECT routine_name FROM information_schema.routines WHERE routine_type != 'OPERATOR'; +``` + +Responses are parsed from `JSONLines_Compact` format. The refresh is debounced: a `refreshing` atomic flag prevents concurrent refreshes. + +On session start, `SchemaCache::refresh()` is called in a background `tokio::spawn` task so startup is non-blocking. + +**TTL**: configurable; defaults to 5 minutes. After the TTL expires, `is_stale()` returns true and `\refresh` or the next session start triggers a new fetch. Use `\refresh` in the REPL to force an immediate refresh. + +### Lookup methods + +| Method | Returns | +|--------|---------| +| `get_tables(prefix)` | Table names matching prefix | +| `get_tables_in_schema(schema, prefix)` | Tables in a specific schema | +| `get_tables_with_schema(prefix)` | `(schema, table)` tuples | +| `get_columns(table, prefix)` | Column names for a table | +| `get_columns_with_table(prefix)` | `(table, column)` tuples | +| `get_schemas(prefix)` | Schema names | +| `get_functions(prefix)` | Function names | +| `get_keywords(prefix)` | SQL keywords | + +All methods take a `prefix: &str` and return only items that start with that prefix (case-insensitive). + +## SqlCompleter (`completion/mod.rs`) + +`complete_at(line, cursor_byte)` is the single entry point: + +1. Extract the partial word before the cursor. +2. Detect completion context (FROM, SELECT, WHERE, etc.) using `ContextDetector`. +3. If the partial word contains `.`, handle schema-qualified or table-qualified completion: + - `schema.` → complete table names in that schema + - `table.` → complete column names in that table +4. Otherwise, generate candidates from all applicable categories (schemas, tables, columns from in-scope tables, functions, keywords). +5. Score every candidate with `PriorityScorer::score()`. +6. Sort descending by score, deduplicate, return. + +### Disabling completion + +`SqlCompleter::set_enabled(false)` makes `complete_at()` return an empty list immediately. Controlled at runtime with `set completion = off;`. + +## PriorityScorer (`completion/priority_scorer.rs`) + +Assigns a numeric score to each candidate. Higher is better. Score = **priority class** + **usage bonus**. + +| Priority class | Score | Applied to | +|----------------|-------|------------| +| 5 | 5 000 | Columns from tables already in the current query (context columns) | +| 4 | 4 000 | Schemas | +| 3 | 3 000 | Tables | +| 2 | 2 000 | Functions | +| 1 | 1 000 | Keywords | +| 0 | 0 | System schemas / system tables | + +Usage bonus: each item used in the last 10 queries adds `100 × frequency` to its score (capped so it can't surpass the next priority class). + +System objects (`information_schema`, `pg_catalog`, `pg_*` tables) are always assigned priority class 0, keeping them at the bottom of any suggestion list. + +## UsageTracker (`completion/usage_tracker.rs`) + +Tracks how many times each table or column has appeared in recently-executed queries. Stored as a sliding window of the last N queries (default: 10). Usage counts decay naturally as old queries fall out of the window. + +`track_query(sql)` is called after each successful query execution (in `query.rs`). It extracts identifiers from the SQL string and increments their counts. + +`get_usage(item) -> u32` returns the count, used by `PriorityScorer`. + +## ContextAnalyzer and ContextDetector + +`ContextAnalyzer::extract_tables(sql)` scans the SQL text for table references (FROM clause, JOIN clauses) using regex patterns. Returns a `Vec` of table names visible in the current statement. These are passed to `PriorityScorer` to identify context columns (priority class 5). + +`ContextDetector::detect(sql, cursor_pos)` determines which SQL clause the cursor sits in. The result (`CompletionContext`) influences which candidate categories are included: + +| Context | Categories offered | +|---------|--------------------| +| `FromClause` | Tables, schemas | +| `SelectClause` | Columns (context-aware), functions, keywords | +| `WhereClause` | Columns, functions, keywords | +| `JoinClause` | Tables, schemas | +| `Unknown` | All categories | + +## Adding new completion sources + +1. Add a `get_*()` method to `SchemaCache` returning candidates for the new category. +2. Add a priority class constant to `PriorityScorer` if the category needs a distinct ranking tier. +3. Call the new method from `SqlCompleter::complete_at()` and pass the results through `PriorityScorer::score()`. +4. If the source requires new schema queries, add them to `SchemaCache::refresh()`. diff --git a/src/docs/output_pipeline.md b/src/docs/output_pipeline.md new file mode 100644 index 0000000..b084048 --- /dev/null +++ b/src/docs/output_pipeline.md @@ -0,0 +1,103 @@ +# Output Pipeline + +This document describes how output travels from business logic (query execution, meta-commands) to the visible text in the TUI output pane, and how the output pane renders it. + +## Overview + +``` +query() / meta_commands() + │ + │ Context::emit*() + ▼ + mpsc::UnboundedSender + │ + │ TuiApp::drain_query_output() (100ms tick) + ▼ + OutputPane::push_*() + │ + │ OutputPane::render() + ▼ + ratatui Buffer → terminal +``` + +## TuiMsg (`src/tui_msg.rs`) + +The channel carries `TuiMsg`, a two-variant enum: + +```rust +pub enum TuiMsg { + Line(String), // plain text (may contain ANSI escape codes) + StyledLines(Vec), // pre-styled table output +} +``` + +`TuiLine` is a `Vec`. `TuiSpan` holds a `String`, a `TuiColor`, and a `bold` flag. This type lives in `tui_msg.rs` and has **no dependency on ratatui** so it can be constructed inside the query task (which also runs in headless mode and must not import TUI types). + +The conversion from `TuiSpan` → ratatui `Span<'static>` happens in `output_pane.rs::tui_span_to_ratatui()`. + +### Why two variants? + +Tables need per-span color (header cyan, NULL dark gray, border plain). Encoding this as ANSI escape codes and then parsing them back is fragile and depends on the rendering library emitting codes unconditionally (comfy-table does not when stdout is not a TTY). `StyledLines` bypasses ANSI entirely by carrying the styling as structured data. + +Plain text output (errors, stats, `\help` text, etc.) is sent as `Line(String)`. It may optionally contain ANSI SGR codes from other sources; those are parsed by `parse_ansi_line()` in the output pane. + +## Context output helpers (`src/context.rs`) + +All output in business-logic code goes through these methods on `Context`: + +| Method | Headless | TUI | +|--------|----------|-----| +| `emit(text)` | `println!` | `TuiMsg::Line` | +| `emit_err(text)` | `eprintln!` | `TuiMsg::Line` | +| `emit_newline()` | `eprintln!()` | `TuiMsg::Line("")` | +| `emit_raw(text)` | `print!` (no trailing newline) | splits on `\n`, sends each as `TuiMsg::Line` | +| `emit_styled_lines(lines)` | no-op | `TuiMsg::StyledLines` | + +`is_tui()` returns `true` when `tui_output_tx` is `Some`. Query code that needs different behaviour in TUI mode (e.g. calling `emit_table_tui()` instead of `render_table_output()`) uses this. + +## drain_query_output (`tui/mod.rs`) + +Called once per event-loop tick before rendering. Drains all pending messages non-blockingly: + +- `TuiMsg::StyledLines` → `output.push_tui_lines()` +- `TuiMsg::Line` starting with `"Time: "` or `"Scanned: "` → `output.push_stat()` +- `TuiMsg::Line` starting with `"Error: "` or `"^C"` → `output.push_error()` +- `TuiMsg::Line` starting with `"Showing first "` while running → captured as `running_hint`, not forwarded +- Everything else → `output.push_ansi_text()` + +Channel `Disconnected` signals that the query task has finished; `is_running` is cleared. + +## OutputPane (`tui/output_pane.rs`) + +Stores all output as a `Vec`. Each `OutputLine` holds a `Line<'static>` — a ratatui line of styled spans — that has already been fully resolved. There is no late-stage style application during render; all conversion happens in the `push_*` methods. + +### Push methods + +| Method | Style applied | +|--------|---------------| +| `push_line(s)` | No style (default terminal color) | +| `push_prompt(s)` | `❯ ` prefix: green+bold; remainder: yellow | +| `push_stat(s)` | Dark gray; splits on `\n` | +| `push_error(s)` | Red; splits on `\n` | +| `push_ansi_text(s)` | Parses ANSI SGR codes; splits on `\n` | +| `push_tui_lines(lines)` | Converts `TuiLine` → ratatui `Line` via `tui_span_to_ratatui()` | + +All push methods call `scroll_to_bottom()` (sets `scroll = usize::MAX` as a sentinel), which is resolved to `total_lines - visible_height` in `clamp_scroll()` before rendering. + +### Scrolling + +`scroll_up(n)` / `scroll_down(n)` adjust `scroll` by `n` lines. `clamp_scroll(height)` is called before each render to keep the offset in bounds and to resolve the `usize::MAX` sentinel. + +### Rendering + +`render(area, buf)` renders only the visible slice `lines[scroll..scroll+height]`. Content is **bottom-anchored**: empty `Line::raw("")` padding is prepended so that when there are fewer lines than the visible height, the content sticks to the bottom of the pane (the output "grows upward" from the input area, like a normal terminal). + +```rust +let padding = height.saturating_sub(content.len()); +let visible = repeat_n(Line::raw(""), padding).chain(content).collect(); +Widget::render(Paragraph::new(visible), area, buf); +``` + +### ANSI SGR parser + +`parse_ansi_line(text)` walks the raw byte string looking for `ESC [` sequences terminated by `m`. It applies `apply_sgr()` to build a running `Style` and flushes text segments as `Span::styled(text, current_style)`. Unrecognized SGR parameters are silently ignored. This parser handles output from non-table sources (e.g. `\help`, comfy-table in headless mode) that may have been formatted with ANSI codes before being written to the channel. diff --git a/src/docs/table_rendering.md b/src/docs/table_rendering.md new file mode 100644 index 0000000..8092240 --- /dev/null +++ b/src/docs/table_rendering.md @@ -0,0 +1,139 @@ +# Table Rendering + +fb-cli has two rendering paths for query results: + +| Path | When used | Output type | +|------|-----------|-------------| +| **Headless** (`render_table*`) | Non-TUI mode | ANSI string written to stdout | +| **TUI** (`render_*_tui_lines`) | TUI mode | `Vec` sent via `TuiMsg::StyledLines` | + +The TUI path produces `TuiLine`/`TuiSpan` values directly, bypassing ANSI entirely. Both paths are in `src/table_renderer.rs`. + +## Entry point in query.rs + +```rust +if context.is_tui() { + emit_table_tui(context, columns, rows, terminal_width, max_cell); +} else { + let rendered = render_table_output(context, columns, rows, terminal_width, max_cell); + out!(context, "{}", rendered); +} +``` + +`emit_table_tui()` subtracts 1 from `terminal_width` before passing it to the renderer. This leaves a 1-column right margin so the table's closing border is never clipped by ratatui's render area. + +## Display modes + +Controlled by `context.args`: + +| `args` method | TUI renderer called | +|---------------|---------------------| +| `is_vertical_display()` | `render_vertical_table_to_tui_lines()` | +| `is_horizontal_display()` | `render_horizontal_forced_tui_lines()` | +| auto (default) | `render_table_to_tui_lines()` — decides at runtime | + +In auto mode, `decide_col_widths()` returns `Some(widths)` for horizontal or `None` to fall back to vertical. + +## Cell value formatting (`fmt_cell`) + +Before layout, every cell value is processed by `fmt_cell(val, max_value_length)`: + +1. `format_value(val)` converts the `serde_json::Value` to a display string: + - `Null` → `"NULL"` + - `String(s)` → `s` (as-is, preserving the value) + - `Number` / `Bool` → `.to_string()` + - `Array` / `Object` → JSON-serialized, hard-capped at 1 000 chars with `... (truncated)` suffix + +2. All control characters (`\n`, `\r`, `\t`, etc.) are replaced with spaces. This is critical: cells that contain multi-line text (e.g. `query_text` storing SQL with newlines) would otherwise produce spans containing `\n`, which ratatui renders as line breaks, causing misaligned borders. + +3. `truncate_to_chars(s, max_value_length)` caps the result at `max_value_length` Unicode scalar values, appending `"..."` if truncated. The limit is `max_cell_length` from args (default: 1 000); for single-column queries it is raised to 5× the normal limit. + +## Auto layout: `decide_col_widths` + +Accepts the formatted cell strings and the available terminal width. Returns `Some(col_widths)` for horizontal layout or `None` for vertical. + +### Available space calculation + +``` +overhead = 3 * n + 1 (for "│ col₀ │ col₁ │ … │" structure) +available = terminal_width - overhead +``` + +### Decision sequence + +1. **Single-column shortcut** — always horizontal, width = `min(terminal_width - 4, natural_capped)`. + +2. **Natural widths** — for each column: `max(header_chars, max_content_chars)`, capped at `terminal_width * 4/5`. + - If `Σ natural_widths ≤ available`: use natural widths as-is. + +3. **Too cramped?** — if `available / n < 10`: return `None` (vertical). + +4. **Minimum widths** — per column: `max(10, ⌈header_len/2⌉, ⌈√max_content_len⌉)`. + - `⌈header_len/2⌉` ensures the header needs at most 2 wrap lines. + - `⌈√max_content_len⌉` keeps cells roughly square (height ≈ width). + - If `Σ min_widths > available`: return `None` (vertical). + +5. **Space distribution** — start with `min_widths`, repeatedly add 1 to each column that hasn't reached its natural width until `available` is exhausted. + +6. **Tall-cell guard** — after distribution, compute how many wrap rows each cell needs: `⌈content_chars / col_width⌉`. If any cell needs more rows than there are columns (`n`), return `None` (vertical). This prevents a single long cell from producing a table taller than it is wide. + +## Horizontal rendering (`render_horizontal_tui`) + +Uses Unicode box-drawing characters: + +``` +┌──────────────┬──────────┐ top border +│ account_name │ value │ header row (cyan + bold) +╞══════════════╪══════════╡ header separator +│ firebolt │ 42 │ data row +├──────────────┼──────────┤ row separator (only when wrapping occurs) +│ other-acct │ 0 │ +└──────────────┴──────────┘ bottom border +``` + +Row separators (`├─┼─┤`) are only added between rows when **any** cell in the table wraps to more than one visual line. When all cells fit on one line, no separators are drawn. + +### `wrap_cell(s, width) -> Vec` + +Splits a string into chunks of exactly `width` Unicode scalar values, padding the last chunk with spaces. Each chunk is suitable for direct use as a ratatui span because: +- All chunks are exactly `width` chars wide (measured by `chars().count()`) +- No control characters exist (sanitized by `fmt_cell`) + +### Border construction (`make_tui_border`) + +Each column occupies `col_width + 2` fill characters between separator characters, matching the `" col_content "` padding in data rows. + +### NULL styling + +NULL cells are rendered in dark gray (`TuiColor::DarkGray`) to visually distinguish them from the empty-string value. + +## Vertical rendering (`render_vertical_table_to_tui_lines`) + +Renders each row as its own mini two-column table: + +``` +Row 1: ← cyan + bold label +┌──────────────┬──────────┐ +│ account_name │ firebolt │ +│ value │ 42 │ +└──────────────┴──────────┘ + ← blank line between rows +Row 2: +… +``` + +Column widths: +- Name column: `max(header_names_len, 30)`, floor 10 +- Value column: `terminal_width - name_col - 7` (7 = overhead for two-column table), floor 10 + +Row separators between fields within a mini-table follow the same rule as horizontal: only drawn when any name or value wraps. + +## Forced-horizontal rendering (`render_horizontal_forced_tui_lines`) + +Calls `decide_col_widths()` first. If it returns `None` (would normally go vertical), falls back to equal-share widths: `available / n` per column (minimum 1). This ensures a horizontal table is always produced regardless of content width. + +## Headless rendering + +`render_table()` uses [comfy-table](https://github.com/Numesson/comfy-table) for non-TUI output. comfy-table does **not** emit ANSI codes when stdout is not a TTY, so the TUI path was built from scratch rather than trying to reuse it. + +`render_table_vertical()` renders a simple two-column plain-text table using `format!` strings without a dependency on comfy-table. diff --git a/src/docs/tui.md b/src/docs/tui.md new file mode 100644 index 0000000..1db8346 --- /dev/null +++ b/src/docs/tui.md @@ -0,0 +1,126 @@ +# TUI Architecture + +fb-cli's interactive REPL is built on [ratatui](https://github.com/ratatui-org/ratatui) with [tui-textarea](https://github.com/rhysd/tui-textarea) for the editor widget and [crossterm](https://github.com/crossterm-rs/crossterm) as the terminal backend. + +## Layout + +``` +┌───────────────────────────────────────────────┐ +│ OUTPUT PANE (scrollable, fills remaining │ +│ space; bottom-anchored: new output grows │ +│ upward from the input area) │ +│ │ +├───────────────────────────────────────────────┤ +│ INPUT PANE (tui-textarea, 1–5 lines visible │ +│ + DarkGray top/bottom borders) │ +├───────────────────────────────────────────────┤ +│ STATUS BAR (1 line, host | db | version) │ +└───────────────────────────────────────────────┘ +``` + +The layout is computed by `tui/layout.rs::compute_layout()`. It reserves a fixed block of rows at the bottom (`RESERVED_INPUT = 5`) so the output pane doesn't jump when the textarea grows to a second line. A transparent spacer is inserted between the input pane and the status bar to absorb the reserved space. When the textarea grows past 5 lines, it starts pushing the output pane upward. + +## Key structs + +| Struct | File | Purpose | +|--------|------|---------| +| `TuiApp` | `tui/mod.rs` | Owns all state; drives the event loop | +| `OutputPane` | `tui/output_pane.rs` | Scrollable output history | +| `History` | `tui/history.rs` | File-backed history with cursor navigation | +| `AppLayout` | `tui/layout.rs` | Three `Rect` values from layout computation | +| `TuiMsg` | `tui_msg.rs` | Channel message type between query task and TUI | + +## Event loop + +`TuiApp::run()` sets up the terminal (raw mode, alternate screen, mouse capture, Kitty keyboard protocol) then delegates to `event_loop()`. + +``` +loop: + 1. drain_query_output() — pull pending TuiMsg items off the channel + 2. increment spinner tick (if query is running) + 3. terminal.draw(render) — paint the frame + 4. event::poll(100ms) — wait for input; tick the spinner even when idle + 5. handle_key() or scroll mouse event +``` + +The 100 ms poll timeout drives the spinner animation without consuming CPU. + +## Query execution + +Queries run in a `tokio::spawn` task, fully asynchronous with respect to the render loop. Communication happens through two primitives: + +- **`mpsc::unbounded_channel::()`** – output from the query flows back to the TUI. When the task finishes (or is cancelled), it drops `ctx`, which drops the sender, which closes the channel. `drain_query_output()` detects the `Disconnected` variant and clears `is_running`. +- **`CancellationToken`** – Ctrl+C cancels the query by calling `token.cancel()`. The query function polls this token and aborts the HTTP request. + +The query task receives a cloned `Context` with `tui_output_tx` and `query_cancel` populated. All output is routed through `Context::emit*()` helpers rather than `println!`, so the same query code works in both headless and TUI modes. + +## Running pane + +While a query is in flight the input textarea is replaced by a compact "running pane" showing a Braille spinner and elapsed time. The `running_hint` string (e.g. "Showing first N rows — collecting remainder...") is also displayed if present. This hint is intercepted from the `TuiMsg::Line` stream in `drain_query_output()` — lines that start with `"Showing first "` are captured in `running_hint` rather than forwarded to the output pane. + +## Key bindings + +| Key | Action | +|-----|--------| +| `Enter` | Submit query if SQL is syntactically complete; otherwise insert newline | +| `Shift+Enter` | Always insert newline (requires Kitty keyboard protocol) | +| `Ctrl+D` | Exit | +| `Ctrl+C` | Cancel input (idle) or cancel in-flight query (running) | +| `Ctrl+V` | Open last result in csvlens viewer | +| `Up` / `Ctrl+Up` | History: navigate backward (Up at row 0 triggers history) | +| `Down` / `Ctrl+Down` | History: navigate forward | +| `PageUp` / `PageDown` | Scroll output pane by 10 lines | +| Mouse scroll | Scroll output pane by 8 lines | +| `\view` + Enter | Open csvlens viewer | +| `\refresh` + Enter | Refresh schema cache | +| `\help` + Enter | Show help in output pane | + +### Kitty keyboard protocol + +`PushKeyboardEnhancementFlags(DISAMBIGUATE_ESCAPE_CODES)` is sent on startup so terminals that support it (kitty, WezTerm, foot, etc.) send distinct codes for `Shift+Enter` vs `Enter`. On terminals that don't support it the flag push is silently ignored; `Shift+Enter` falls through to inserting a newline via the textarea's default handling. + +### Query submission heuristic + +The TUI calls `try_split_queries()` from `query.rs`, which uses the Pest SQL grammar to split on semicolons while respecting strings, comments, and dollar-quoted blocks. If the text splits into at least one complete query the whole buffer is submitted. If not (e.g. the user is mid-statement), a newline is inserted instead. + +## Rendering pipeline + +``` +TuiApp::render() + ├── compute_layout(area, input_height) + ├── output.clamp_scroll(layout.output.height) + ├── output.render(layout.output, buf) ← OutputPane + ├── render_running_pane() OR render_input() ← tui-textarea / spinner + └── render_status_bar() +``` + +Colors used: +- Output pane: prompt `❯` = green+bold, SQL text = yellow, stats = dark gray, errors = red, table headers = cyan+bold, NULL values = dark gray +- Input pane: `❯` prompt = green, border = dark gray +- Status bar: dark gray background, white text, right side shows `Ctrl+C cancel` while running + +## History (`tui/history.rs`) + +`History` stores entries in memory and appends each new entry to `~/.firebolt/fb_history` (one entry per line). Multi-line entries are stored with embedded newlines escaped as `\\n`. + +Navigation state: +- `cursor: Option` — `None` means not navigating. `Some(i)` means showing `entries[i]`. +- `saved_current: String` — the text that was in the editor when the user first pressed Up, restored when they press Down past the newest entry. + +Consecutive duplicate entries are not stored. The in-memory list is capped at 10 000 entries; the file grows unboundedly (trimming is applied on load). + +## csvlens integration + +`\view` (or Ctrl+V) calls `open_viewer()`, which: +1. `disable_raw_mode()` + `LeaveAlternateScreen` — yields the terminal +2. Spawns `csvlens` as a subprocess via `viewer::open_csvlens_viewer()` +3. `enable_raw_mode()` + `EnterAlternateScreen` — reclaims the terminal +4. Sets `needs_clear = true` so ratatui does a full repaint on the next tick + +## Adding new key bindings + +Add a match arm in `handle_key()` in `tui/mod.rs`. For bindings that only apply while running, add them in the early return block at the top of `handle_key()`. + +## Adding new backslash commands + +Add a match arm in `handle_backslash_command()`. Persistent parameter changes (e.g. `set format = ...`) should go through `handle_meta_command()` in `meta_commands.rs` so they work in both TUI and headless mode. diff --git a/src/highlight.rs b/src/highlight.rs index dbc952b..109632b 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1,7 +1,5 @@ use once_cell::sync::Lazy; use regex::Regex; -use rustyline::highlight::Highlighter; -use std::borrow::Cow; /// Color scheme using ANSI escape codes pub struct ColorScheme { @@ -17,12 +15,12 @@ pub struct ColorScheme { impl ColorScheme { fn new() -> Self { Self { - keyword: "\x1b[94m", // Bright Blue - SQL keywords stand out clearly - function: "\x1b[96m", // Bright Cyan - distinct from keywords - string: "\x1b[93m", // Bright Yellow - conventional choice (DuckDB, pgcli, VSCode) - number: "\x1b[95m", // Bright Magenta - clear distinction - comment: "\x1b[90m", // Bright Black (gray) - better visibility than dim - operator: "\x1b[0m", // Default/Reset - subtle, doesn't compete visually + keyword: "\x1b[94m", // Bright Blue + function: "\x1b[96m", // Bright Cyan + string: "\x1b[93m", // Bright Yellow + number: "\x1b[95m", // Bright Magenta + comment: "\x1b[90m", // Bright Black (gray) + operator: "\x1b[0m", // Default/Reset reset: "\x1b[0m", // Reset All } } @@ -46,23 +44,19 @@ static NUMBER_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\b\d+\.?\d*([eE][ // Comment patterns static LINE_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"--[^\n]*").unwrap()); - static BLOCK_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"/\*[\s\S]*?\*/").unwrap()); // Operator pattern static OPERATOR_PATTERN: Lazy = Lazy::new(|| Regex::new(r"[=<>!]+|[+\-*/%]|\|\||::").unwrap()); -/// SQL syntax highlighter using regex patterns +/// SQL syntax highlighter using regex patterns. +/// Produces ANSI-escaped strings for headless (non-TUI) display. pub struct SqlHighlighter { color_scheme: ColorScheme, enabled: bool, } impl SqlHighlighter { - /// Create a new SQL highlighter - /// - /// # Arguments - /// * `enabled` - Whether syntax highlighting should be enabled pub fn new(enabled: bool) -> Result> { Ok(Self { color_scheme: ColorScheme::new(), @@ -70,16 +64,15 @@ impl SqlHighlighter { }) } - /// Highlight SQL text by applying ANSI color codes - fn highlight_sql(&self, line: &str) -> String { + /// Highlight SQL text by applying ANSI color codes. + pub fn highlight_sql(&self, line: &str) -> String { if !self.enabled || line.is_empty() { return line.to_string(); } - // Collect all matches with their positions and colors let mut highlights: Vec<(usize, usize, &str)> = Vec::new(); - // Match comments first (highest priority to prevent highlighting within comments) + // Comments first (highest priority) for mat in LINE_COMMENT_PATTERN.find_iter(line) { highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); } @@ -87,12 +80,12 @@ impl SqlHighlighter { highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); } - // Match strings (high priority to prevent highlighting keywords in strings) + // Strings (high priority) for mat in STRING_PATTERN.find_iter(line) { highlights.push((mat.start(), mat.end(), self.color_scheme.string)); } - // Collect other matches separately to avoid borrow checker issues + // Collect other matches let mut keyword_matches = Vec::new(); for mat in KEYWORD_PATTERN.find_iter(line) { keyword_matches.push((mat.start(), mat.end())); @@ -100,7 +93,6 @@ impl SqlHighlighter { let mut function_matches = Vec::new(); for mat in FUNCTION_PATTERN.find_iter(line) { - // Don't include the opening parenthesis function_matches.push((mat.start(), mat.end() - 1)); } @@ -114,71 +106,53 @@ impl SqlHighlighter { operator_matches.push((mat.start(), mat.end())); } - // Add matches that don't overlap with existing highlights (strings/comments) + // Add non-overlapping matches + let overlaps = |start: usize, end: usize, existing: &[(usize, usize, &str)]| { + existing.iter().any(|(s, e, _)| { + (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e) + }) + }; + for (start, end) in keyword_matches { - let overlaps = highlights - .iter() - .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); - if !overlaps { + if !overlaps(start, end, &highlights) { highlights.push((start, end, self.color_scheme.keyword)); } } - for (start, end) in function_matches { - let overlaps = highlights - .iter() - .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); - if !overlaps { + if !overlaps(start, end, &highlights) { highlights.push((start, end, self.color_scheme.function)); } } - for (start, end) in number_matches { - let overlaps = highlights - .iter() - .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); - if !overlaps { + if !overlaps(start, end, &highlights) { highlights.push((start, end, self.color_scheme.number)); } } - for (start, end) in operator_matches { - let overlaps = highlights - .iter() - .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); - if !overlaps { + if !overlaps(start, end, &highlights) { highlights.push((start, end, self.color_scheme.operator)); } } - // Sort highlights by start position highlights.sort_by_key(|h| h.0); - // Build highlighted string by inserting ANSI codes let mut result = String::with_capacity(line.len() * 2); let mut last_pos = 0; let reset = self.color_scheme.reset; for (start, end, color) in highlights { - // Skip overlapping highlights (shouldn't happen but just in case) if start < last_pos { continue; } - - // Add text before highlight if start > last_pos { result.push_str(&line[last_pos..start]); } - - // Add colored text result.push_str(color); result.push_str(&line[start..end]); result.push_str(reset); - last_pos = end; } - // Add remaining text if last_pos < line.len() { result.push_str(&line[last_pos..]); } @@ -187,25 +161,6 @@ impl SqlHighlighter { } } -impl Highlighter for SqlHighlighter { - fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { - if !self.enabled { - return Cow::Borrowed(line); - } - - let highlighted = self.highlight_sql(line); - if highlighted == line { - Cow::Borrowed(line) - } else { - Cow::Owned(highlighted) - } - } - - fn highlight_char(&self, _line: &str, _pos: usize) -> bool { - self.enabled - } -} - #[cfg(test)] mod tests { use super::*; @@ -213,7 +168,7 @@ mod tests { #[test] fn test_disabled_highlighter() { let highlighter = SqlHighlighter::new(false).unwrap(); - let result = highlighter.highlight("SELECT * FROM users", 0); + let result = highlighter.highlight_sql("SELECT * FROM users"); assert_eq!(result, "SELECT * FROM users"); } @@ -221,37 +176,37 @@ mod tests { fn test_keyword_highlighting() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT"); - assert!(result.contains("\x1b[94m")); // Contains blue color code + assert!(result.contains("\x1b[94m")); assert!(result.contains("SELECT")); - assert!(result.contains("\x1b[0m")); // Contains reset code + assert!(result.contains("\x1b[0m")); } #[test] fn test_string_highlighting() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT 'hello'"); - assert!(result.contains("\x1b[93m")); // Contains yellow color code for string + assert!(result.contains("\x1b[93m")); } #[test] fn test_number_highlighting() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT 42"); - assert!(result.contains("\x1b[95m")); // Contains magenta color code for number + assert!(result.contains("\x1b[95m")); } #[test] fn test_comment_highlighting() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("-- comment"); - assert!(result.contains("\x1b[90m")); // Contains bright black (gray) color code + assert!(result.contains("\x1b[90m")); } #[test] fn test_function_highlighting() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT COUNT(*)"); - assert!(result.contains("\x1b[96m")); // Contains cyan color code for function + assert!(result.contains("\x1b[96m")); } #[test] @@ -259,12 +214,8 @@ mod tests { let highlighter = SqlHighlighter::new(true).unwrap(); let query = "SELECT id, name FROM users WHERE status = 'active'"; let result = highlighter.highlight_sql(query); - - // Should contain keyword colors (blue) assert!(result.contains("\x1b[94m")); - // Should contain string colors (yellow) assert!(result.contains("\x1b[93m")); - // Should contain reset codes assert!(result.contains("\x1b[0m")); } @@ -272,8 +223,6 @@ mod tests { fn test_keywords_in_strings_not_highlighted() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT 'SELECT FROM WHERE'"); - - // Count the number of keyword color codes - should only be one (for the first SELECT) let keyword_count = result.matches("\x1b[94m").count(); assert_eq!(keyword_count, 1); } @@ -281,9 +230,7 @@ mod tests { #[test] fn test_malformed_sql_graceful() { let highlighter = SqlHighlighter::new(true).unwrap(); - // This might not parse perfectly, but should not crash let result = highlighter.highlight_sql("SELECT FROM WHERE"); - // Should still highlight keywords even in malformed SQL assert!(result.contains("\x1b[94m")); } @@ -297,18 +244,16 @@ mod tests { #[test] fn test_multiline_fragment() { let highlighter = SqlHighlighter::new(true).unwrap(); - // Each line is highlighted independently in REPL let result = highlighter.highlight_sql("FROM users"); - assert!(result.contains("\x1b[94m")); // FROM should be highlighted + assert!(result.contains("\x1b[94m")); } #[test] fn test_operators() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("WHERE x = 1 AND y > 2"); - // Operators are now subtle (default color) but query should highlight successfully - assert!(result.contains("\x1b[94m")); // WHERE and AND should be highlighted as keywords - assert!(result.contains("=")); // Operators should still be present + assert!(result.contains("\x1b[94m")); + assert!(result.contains("=")); assert!(result.contains(">")); } @@ -316,6 +261,6 @@ mod tests { fn test_block_comment() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT /* comment */ 1"); - assert!(result.contains("\x1b[90m")); // Should contain bright black (gray) color code + assert!(result.contains("\x1b[90m")); } } diff --git a/src/main.rs b/src/main.rs index 0154353..0bb4532 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ -use rustyline::{config::Configurer, error::ReadlineError, Cmd, Editor, EventHandler, KeyCode, KeyEvent, Modifiers}; use std::io::IsTerminal; +use std::sync::Arc; mod args; mod auth; @@ -8,8 +8,9 @@ mod context; mod highlight; mod meta_commands; mod query; -mod repl_helper; mod table_renderer; +mod tui; +mod tui_msg; mod utils; mod viewer; @@ -17,15 +18,9 @@ use args::get_args; use auth::maybe_authenticate; use completion::schema_cache::SchemaCache; use completion::usage_tracker::UsageTracker; -use completion::SqlCompleter; use context::Context; -use highlight::SqlHighlighter; -use meta_commands::handle_meta_command; -use query::{query, try_split_queries}; -use repl_helper::ReplHelper; -use std::sync::Arc; -use utils::history_path; -use viewer::open_csvlens_viewer; +use query::query; +use tui::TuiApp; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); @@ -46,9 +41,10 @@ async fn main() -> Result<(), Box> { let query_text = if context.args.command.is_empty() { context.args.query.join(" ") } else { - format!("{} {}", context.args.command, context.args.query.join(" ")).to_string() + format!("{} {}", context.args.command, context.args.query.join(" ")) }; + // ── Headless mode: query provided on the command line ──────────────────── if !query_text.is_empty() { query(&mut context, query_text).await?; return Ok(()); @@ -57,238 +53,60 @@ async fn main() -> Result<(), Box> { let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); context.is_interactive = is_tty; - // Determine if colors should be used - let should_highlight = is_tty && context.args.should_use_colors(); - - // Initialize highlighter with error handling - let highlighter = SqlHighlighter::new(should_highlight).unwrap_or_else(|e| { - if context.args.verbose { - eprintln!("Failed to initialize syntax highlighting: {}", e); - } - SqlHighlighter::new(false).unwrap() // Fallback to disabled - }); - - // Initialize schema cache and usage tracker for completion - let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); - let usage_tracker = Arc::new(UsageTracker::new(10)); // Track last 10 queries - let completer = SqlCompleter::new(schema_cache.clone(), usage_tracker.clone(), !context.args.no_completion); - - // Store usage tracker in context for query tracking - context.usage_tracker = Some(usage_tracker.clone()); - - let helper = ReplHelper::new(highlighter, completer); - let mut rl: Editor = Editor::new()?; - rl.set_helper(Some(helper)); - - - let history_path = history_path()?; - rl.set_max_history_size(10_000)?; - if rl.load_history(&history_path).is_err() { - if is_tty { - eprintln!("No previous history"); - } - } else if context.args.verbose { - eprintln!("Loaded history from {:?} and set max_history_size = 10'000", history_path) - } - - rl.bind_sequence(KeyEvent(KeyCode::Char('o'), Modifiers::CTRL), EventHandler::Simple(Cmd::Newline)); - - // Bind Ctrl-V to trigger viewer via special marker - // Using Cmd::AcceptLine alone won't work because we need to detect it was Ctrl-V - // Instead, we'll keep the two-step approach (Ctrl-V + Enter) which is explicit and clear - rl.bind_sequence( - KeyEvent(KeyCode::Char('v'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())), - ); + // ── Non-interactive (pipe/redirect): fall through to headless behaviour ── + if !is_tty { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut buffer = String::new(); + let mut has_error = false; - // Spawn background task to refresh schema cache if completion is enabled - if !context.args.no_completion && is_tty { - let cache = schema_cache.clone(); - let mut ctx_clone = context.clone(); - tokio::spawn(async move { - if let Err(e) = cache.refresh(&mut ctx_clone).await { - // Report errors to help debug - eprintln!("Failed to refresh schema cache: {}", e); - } - }); - } + for line in stdin.lock().lines() { + let line = line?; + buffer.push_str(&line); - if is_tty && !context.args.concise { - eprintln!("Type \\help for available commands or press Ctrl+V then Enter to view last result. Ctrl+D to exit."); - } - let mut buffer: String = String::new(); - let mut has_error = false; - loop { - let prompt = if !is_tty { - // No prompt when stdout is not a terminal (e.g., piped) - "" - } else if !buffer.trim_start().is_empty() { - // Continuation prompt (PROMPT2) - if let Some(custom_prompt) = &context.prompt2 { - custom_prompt.as_str() - } else { - "~> " - } - } else if context.args.extra.iter().any(|arg| arg.starts_with("transaction_id=")) { - // Transaction prompt (PROMPT3) - if let Some(custom_prompt) = &context.prompt3 { - custom_prompt.as_str() - } else { - "*> " - } - } else { - // Normal prompt (PROMPT1) - if let Some(custom_prompt) = &context.prompt1 { - custom_prompt.as_str() - } else { - "=> " + // Handle quit/exit before appending a newline (mirrors old REPL behaviour) + if buffer.trim() == "quit" || buffer.trim() == "exit" { + buffer.clear(); // don't process as SQL + break; } - }; - let readline = rl.readline(prompt); - match readline { - Ok(line) => { - // Check for special commands - let trimmed = line.trim(); + buffer.push('\n'); - if trimmed == "\\view" { - // Open csvlens viewer for last query result - if let Err(e) = open_csvlens_viewer(&context) { - eprintln!("Failed to open viewer: {}", e); + let queries = query::try_split_queries(&buffer).unwrap_or_default(); + if !queries.is_empty() { + for q in queries { + if query(&mut context, q).await.is_err() { + has_error = true; } - continue; - } else if trimmed == "\\refresh" || trimmed == "\\refresh_cache" { - // Refresh schema cache for auto-completion - if !context.args.no_completion { - let cache = schema_cache.clone(); - let mut ctx_clone = context.clone(); - if is_tty { - eprintln!("Refreshing schema cache..."); - } - tokio::spawn(async move { - if let Err(e) = cache.refresh(&mut ctx_clone).await { - eprintln!("Failed to refresh schema cache: {}", e); - } else { - eprintln!("Schema cache refreshed successfully"); - } - }); - } else { - eprintln!("Auto-completion is disabled. Enable it with: set completion = on;"); - } - continue; - } else if trimmed == "\\help" { - // Show help for special commands - eprintln!("Special commands:"); - eprintln!(" \\view - Open last query result in csvlens viewer"); - eprintln!(" (requires client format: client:auto, client:vertical, or client:horizontal)"); - eprintln!(" \\refresh - Manually refresh schema cache for auto-completion"); - eprintln!(" \\help - Show this help message"); - eprintln!(); - eprintln!("SQL-style commands:"); - eprintln!(" set format = ; - Change output format"); - eprintln!(" unset format; - Reset format to default"); - eprintln!(" set completion = on/off; - Enable/disable auto-completion"); - eprintln!(" unset completion; - Reset completion to default"); - eprintln!(); - eprintln!("Format values:"); - eprintln!(" Client-side rendering (prefix with 'client:'):"); - eprintln!(" client:auto - Smart switching between horizontal/vertical (default in interactive)"); - eprintln!(" client:horizontal - Force horizontal table layout"); - eprintln!(" client:vertical - Force vertical two-column layout"); - eprintln!(); - eprintln!(" Server-side rendering (no prefix):"); - eprintln!(" PSQL - PostgreSQL-style format (default in non-interactive)"); - eprintln!(" JSON - JSON format"); - eprintln!(" CSV - CSV format"); - eprintln!(" TabSeparatedWithNames - TSV with headers"); - eprintln!(" JSONLines_Compact - JSON Lines format"); - eprintln!(); - eprintln!("Examples:"); - eprintln!(" set format = client:vertical; # Use client-side vertical display"); - eprintln!(" set format = JSON; # Use server-side JSON output"); - eprintln!(); - eprintln!("Keyboard shortcuts:"); - eprintln!(" Ctrl+V then Enter - Open last query result in csvlens viewer (inserts \\view)"); - eprintln!(" Ctrl+O - Insert newline (for multi-line queries)"); - eprintln!(" Ctrl+D - Exit REPL"); - eprintln!(" Ctrl+C - Cancel current input"); - continue; } - - buffer += line.as_str(); - - if buffer.trim() == "quit" || buffer.trim() == "exit" { - break; - } - - buffer += "\n"; - if !line.is_empty() { - // Check if this is a meta-command (backslash command) - if line.trim().starts_with('\\') { - if let Err(e) = handle_meta_command(&mut context, line.trim()) { - eprintln!("Error processing meta-command: {}", e); - } - buffer.clear(); - continue; - } - - let queries = try_split_queries(&buffer).unwrap_or_default(); - - if !queries.is_empty() { - rl.add_history_entry(buffer.trim())?; - rl.append_history(&history_path)?; - - for q in queries { - if query(&mut context, q).await.is_err() { - has_error = true; - } - } - - // Update completer enabled state after processing queries - // (in case set completion = on/off was executed) - if let Some(helper) = rl.helper_mut() { - helper.completer_mut().set_enabled(!context.args.no_completion); - } - - buffer.clear(); - } - } - } - Err(ReadlineError::Interrupted) => { - eprintln!("^C"); buffer.clear(); } - Err(ReadlineError::Eof) => { - if !buffer.trim().is_empty() { - buffer += ";"; - match try_split_queries(&buffer) { - None => {} - Some(queries) => { - for q in queries { - rl.add_history_entry(q.trim())?; - rl.append_history(&history_path)?; - if query(&mut context, q).await.is_err() { - has_error = true; - } - } - } + } + + // Try remaining buffer with implicit semicolon (EOF with incomplete query) + if !buffer.trim().is_empty() { + let text = format!("{};", buffer.trim()); + if let Some(queries) = query::try_split_queries(&text) { + for q in queries { + if query(&mut context, q).await.is_err() { + has_error = true; } } - break; - } - Err(err) => { - eprintln!("Error: {:?}", err); - has_error = true; - break; } } - } - if context.args.verbose { - eprintln!("Saved history to {:?}", history_path) + return if has_error { Err("One or more queries failed".into()) } else { Ok(()) }; } - if has_error { + // ── Interactive TUI mode ───────────────────────────────────────────────── + let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); + let usage_tracker = Arc::new(UsageTracker::new(10)); + context.usage_tracker = Some(usage_tracker); + + let app = TuiApp::new(context, schema_cache); + let had_error = app.run().await?; + + if had_error { Err("One or more queries failed".into()) } else { Ok(()) diff --git a/src/query.rs b/src/query.rs index 3bae7bc..89b8230 100644 --- a/src/query.rs +++ b/src/query.rs @@ -14,6 +14,22 @@ use crate::utils::spin; use crate::FIREBOLT_PROTOCOL_VERSION; use crate::USER_AGENT; +// ── Output helpers ──────────────────────────────────────────────────────────── +// These macros route output through the TUI channel when running in TUI mode, +// otherwise fall back to println!/eprintln! as before. + +macro_rules! out { + ($ctx:expr, $($arg:tt)*) => { + $ctx.emit(format!($($arg)*)) + }; +} + +macro_rules! out_err { + ($ctx:expr, $($arg:tt)*) => { + $ctx.emit_err(format!($($arg)*)) + }; +} + // Format bytes with appropriate unit (B, KB, MB, GB, TB) fn format_bytes(bytes: u64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; @@ -113,7 +129,7 @@ pub fn set_args(context: &mut Context, query: &str) -> Result Result Result], + terminal_width: u16, + max_cell_length: usize, +) -> String { + if context.args.is_horizontal_display() { + table_renderer::render_table(columns, rows, max_cell_length) + } else if context.args.is_vertical_display() { + table_renderer::render_table_vertical(columns, rows, terminal_width, max_cell_length) + } else if context.args.is_auto_display() { + if table_renderer::should_use_vertical_mode(columns, terminal_width, context.args.min_col_width) { + table_renderer::render_table_vertical(columns, rows, terminal_width, max_cell_length) + } else { + table_renderer::render_table(columns, rows, max_cell_length) + } + } else { + table_renderer::render_table(columns, rows, max_cell_length) + } +} + +/// In TUI mode, emit a result table as pre-styled TuiLines (no ANSI round-trip). +fn emit_table_tui( + context: &Context, + columns: &[table_renderer::ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_cell_length: usize, +) { + // Leave a 1-column margin so the rightmost border isn't clipped by ratatui. + let tw = terminal_width.saturating_sub(1); + let lines = if context.args.is_vertical_display() { + table_renderer::render_vertical_table_to_tui_lines(columns, rows, tw, max_cell_length) + } else if context.args.is_horizontal_display() { + table_renderer::render_horizontal_forced_tui_lines(columns, rows, tw, max_cell_length) + } else { + table_renderer::render_table_to_tui_lines(columns, rows, tw, max_cell_length) + }; + context.emit_styled_lines(lines); +} + +/// Extract a human-readable scan-statistics string from the FINISH_SUCCESSFULLY payload. +fn compute_stats(concise: bool, statistics: &Option) -> Option { + if concise || statistics.is_none() { + return None; + } + statistics.as_ref().and_then(|stats| { + stats.as_object().map(|obj| { + let scanned_cache = obj.get("scanned_bytes_cache").and_then(|v| v.as_u64()).unwrap_or(0); + let scanned_storage = obj.get("scanned_bytes_storage").and_then(|v| v.as_u64()).unwrap_or(0); + let rows_read = obj.get("rows_read").and_then(|v| v.as_u64()).unwrap_or(0); + let total_scanned = scanned_cache + scanned_storage; + if rows_read > 0 || total_scanned > 0 { + Some(format!( + "Scanned: {} rows, {} ({} local, {} remote)", + format_number(rows_read), + format_bytes(total_scanned), + format_bytes(scanned_cache), + format_bytes(scanned_storage), + )) + } else { + None + } + }).flatten() + }) +} + // Send query and print result. pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box> { // Handle set/unset commands if set_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii { - eprintln!("URL: {}", context.url); + if !context.args.concise && !context.args.hide_pii && !context.is_tui() { + out_err!(context, "URL: {}", context.url); } return Ok(()); } if unset_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii { - eprintln!("URL: {}", context.url); + if !context.args.concise && !context.args.hide_pii && !context.is_tui() { + out_err!(context, "URL: {}", context.url); } return Ok(()); @@ -209,8 +294,8 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } if context.args.verbose { - eprintln!("URL: {}", context.url); - eprintln!("QUERY: {}", query_text); + out_err!(context, "URL: {}", context.url); + out_err!(context, "QUERY: {}", query_text); } let start = Instant::now(); @@ -240,7 +325,8 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let async_resp = request.send(); let finish_token = CancellationToken::new(); - let maybe_spin = if context.args.no_spinner || context.args.concise { + // Skip the spinner when running inside the TUI (it has its own progress indicator). + let maybe_spin = if context.is_tui() || context.args.no_spinner || context.args.concise { None } else { let token_clone = finish_token.clone(); @@ -249,16 +335,25 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< })) }; + // Build a cancel future: prefer the TUI's CancellationToken, fall back to SIGINT. + let cancel = context.query_cancel.clone(); + let mut query_failed = false; select! { - _ = signal::ctrl_c() => { + _ = async { + if let Some(token) = cancel { + token.cancelled().await; + } else { + let _ = signal::ctrl_c().await; + } + } => { finish_token.cancel(); if let Some(spin) = maybe_spin { spin.await?; } if !context.args.concise { - eprintln!("^C"); + out_err!(context, "^C"); } query_failed = true; } @@ -271,7 +366,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let mut maybe_request_id: Option = None; match response { - Ok(resp) => { + Ok(mut resp) => { let mut updated_url = false; for (header, value) in resp.headers() { if header == "firebolt-remove-parameters" { @@ -307,127 +402,181 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< updated_url = true; } } - if updated_url && !context.args.concise && !context.args.hide_pii { - eprintln!("URL: {}", context.url); + if updated_url && !context.args.concise && !context.args.hide_pii && !context.is_tui() { + out_err!(context, "URL: {}", context.url); } let status = resp.status(); - let body = resp.text().await?; - - // on stdout, on purpose - if context.args.should_render_table() { - match table_renderer::parse_jsonlines_compact(&body) { - Ok(parsed) => { - // Store result for interactive viewing - context.last_result = Some(parsed.clone()); - - if let Some(errors) = parsed.errors { - // Display errors - for error in errors { - eprintln!("Error: {}", error.description); + + if context.args.should_render_table() && context.is_interactive { + // ── Streaming path: emit display rows at the limit, + // keep collecting all rows for csvlens. ──────────── + use table_renderer::{ErrorDetail, JsonLineMessage, ParsedResult, ResultColumn}; + + let mut columns: Vec = Vec::new(); + let mut display_rows: Vec> = Vec::new(); + let mut all_rows: Vec> = Vec::new(); + let mut statistics: Option = None; + let mut errors: Option> = None; + let mut display_emitted = false; + let mut display_byte_count = 0usize; + + let terminal_width = terminal_size::terminal_size() + .map(|(terminal_size::Width(w), _)| w) + .unwrap_or(80); + + let mut line_buf = String::new(); + let mut stream_err: Option = None; + + 'stream: loop { + match resp.chunk().await { + Err(e) => { stream_err = Some(e.to_string()); break 'stream; } + Ok(None) => break 'stream, + Ok(Some(chunk)) => { + match std::str::from_utf8(&chunk) { + Err(_) => { stream_err = Some("Invalid UTF-8 in response".into()); break 'stream; } + Ok(s) => line_buf.push_str(s), } - } else if !parsed.columns.is_empty() { - // Get terminal width for intelligent display decisions - let terminal_width = terminal_size::terminal_size() - .map(|(terminal_size::Width(w), _)| w) - .unwrap_or(80); - - let max_cell_length = if parsed.columns.len() == 1 { - context.args.max_cell_length * 5 - } else { - context.args.max_cell_length - }; - - let (display_rows, limit_msg) = if context.is_interactive { - apply_output_limits(&parsed.rows) - } else { - (&parsed.rows[..], None) - }; - - let table_output = if context.args.is_horizontal_display() { - // Force horizontal table layout - table_renderer::render_table(&parsed.columns, display_rows, max_cell_length) - } else if context.args.is_vertical_display() { - // Force vertical two-column layout - table_renderer::render_table_vertical(&parsed.columns, display_rows, terminal_width, max_cell_length) - } else if context.args.is_auto_display() { - // Auto mode - intelligently choose display mode - if table_renderer::should_use_vertical_mode(&parsed.columns, terminal_width, context.args.min_col_width) { - if context.args.verbose { - eprintln!("Note: Using vertical display mode (table too wide for horizontal display)"); + while let Some(nl) = line_buf.find('\n') { + let line = line_buf[..nl].trim().to_string(); + line_buf.drain(..nl + 1); + if line.is_empty() { continue; } + + match serde_json::from_str::(&line) { + Err(e) => { stream_err = Some(e.to_string()); break 'stream; } + Ok(msg) => match msg { + JsonLineMessage::Start { result_columns, .. } => { + columns = result_columns; + } + JsonLineMessage::Data { data } => { + for row in data { + if !display_emitted { + let row_bytes: usize = row.iter().map(|v| v.to_string().len()).sum(); + let over_rows = display_rows.len() >= INTERACTIVE_MAX_ROWS; + let over_bytes = display_byte_count + row_bytes > INTERACTIVE_MAX_BYTES; + if over_rows || over_bytes { + let max_cell = if columns.len() == 1 { context.args.max_cell_length * 5 } else { context.args.max_cell_length }; + if context.is_tui() { + emit_table_tui(context, &columns, &display_rows, terminal_width, max_cell); + } else { + let rendered = render_table_output(context, &columns, &display_rows, terminal_width, max_cell); + out!(context, "{}", rendered); + } + out_err!(context, "Showing first {} rows — collecting remainder for \\view...", + format_number(display_rows.len() as u64)); + display_emitted = true; + } else { + display_byte_count += row_bytes; + display_rows.push(row.clone()); + } + } + all_rows.push(row); + } + } + JsonLineMessage::FinishSuccessfully { statistics: s } => { + statistics = s; + } + JsonLineMessage::FinishWithErrors { errors: errs } => { + errors = Some(errs); + } } - table_renderer::render_table_vertical(&parsed.columns, display_rows, terminal_width, max_cell_length) - } else { - table_renderer::render_table(&parsed.columns, display_rows, max_cell_length) } - } else { - // Fallback to horizontal if format starts with client: but mode not recognized - table_renderer::render_table(&parsed.columns, display_rows, max_cell_length) - }; - - println!("{}", table_output); - - if let Some(msg) = limit_msg { - eprintln!("{}", msg); } - - // Store statistics for display later (after Time) - context.last_stats = if !context.args.concise && parsed.statistics.is_some() { - parsed.statistics.as_ref().and_then(|stats| { - stats.as_object().map(|obj| { - let scanned_cache = obj.get("scanned_bytes_cache") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let scanned_storage = obj.get("scanned_bytes_storage") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let rows_read = obj.get("rows_read") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let total_scanned = scanned_cache + scanned_storage; - - // Format: "Scanned: x rows, y B (..B local, ..B remote)" - if rows_read > 0 || total_scanned > 0 { - Some(format!( - "Scanned: {} rows, {} ({} local, {} remote)", - format_number(rows_read), - format_bytes(total_scanned), - format_bytes(scanned_cache), - format_bytes(scanned_storage) - )) - } else { - None - } - }).flatten() - }) - } else { - None - }; } } - Err(e) => { - // Fallback to raw output on parse error - if context.args.verbose { - eprintln!("Failed to parse table format: {}", e); + } + + if let Some(e) = stream_err { + if context.args.verbose { + out_err!(context, "Failed to read/parse response: {}", e); + } else { + out_err!(context, "Error: {}", e); + } + query_failed = true; + } else if let Some(errs) = errors { + for err in errs { + out_err!(context, "Error: {}", err.description); + } + query_failed = true; + } else if !columns.is_empty() { + if !display_emitted { + // All rows fit within limits — emit now + let max_cell = if columns.len() == 1 { context.args.max_cell_length * 5 } else { context.args.max_cell_length }; + if context.is_tui() { + emit_table_tui(context, &columns, &all_rows, terminal_width, max_cell); + } else { + let rendered = render_table_output(context, &columns, &all_rows, terminal_width, max_cell); + out!(context, "{}", rendered); } - print!("{}", body); + } else { + // Partial display was already emitted; show the final total + out_err!(context, "Showing {} of {} rows (use \\view to see all).", + format_number(display_rows.len() as u64), + format_number(all_rows.len() as u64)); } + context.last_result = Some(ParsedResult { + columns: columns.clone(), + rows: all_rows, + statistics: statistics.clone(), + errors: None, + }); + context.last_stats = compute_stats(context.args.concise, &statistics); + } + + if !status.is_success() { + query_failed = true; } } else { - // Original behavior for other formats - print!("{}", body); - } + // ── Buffered path (non-interactive or server-rendered) ── + let body = resp.text().await?; + + if context.args.should_render_table() { + match table_renderer::parse_jsonlines_compact(&body) { + Ok(parsed) => { + context.last_result = Some(parsed.clone()); + if let Some(errors) = parsed.errors { + for error in errors { + out_err!(context, "Error: {}", error.description); + } + } else if !parsed.columns.is_empty() { + let terminal_width = terminal_size::terminal_size() + .map(|(terminal_size::Width(w), _)| w) + .unwrap_or(80); + let max_cell_length = if parsed.columns.len() == 1 { + context.args.max_cell_length * 5 + } else { + context.args.max_cell_length + }; + if context.is_tui() { + emit_table_tui(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); + } else { + let table_output = render_table_output(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); + out!(context, "{}", table_output); + } + context.last_stats = compute_stats(context.args.concise, &parsed.statistics); + } + } + Err(e) => { + if context.args.verbose { + out_err!(context, "Failed to parse table format: {}", e); + } + context.emit_raw(&body); + } + } + } else { + context.emit_raw(&body); + } - if !status.is_success() { - query_failed = true; + if !status.is_success() { + query_failed = true; + } } } Err(error) => { if context.args.verbose { - eprintln!("Failed to send the request: {:?}", error); + out_err!(context, "Failed to send the request: {:?}", error); } else { - eprintln!("Failed to send the request: {}", error.to_string()); + out_err!(context, "Failed to send the request: {}", error.to_string()); } query_failed = true; }, @@ -435,15 +584,16 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if !context.args.concise { let elapsed = format!("{:?}", elapsed / 100000 * 100000); - eprintln!("Time: {elapsed}"); - // Print statistics if available (from client-side rendering) + out_err!(context, "Time: {elapsed}"); if let Some(stats) = &context.last_stats { - eprintln!("{}", stats); + out_err!(context, "{}", stats); } if let Some(request_id) = maybe_request_id { - eprintln!("Request Id: {request_id}"); + if !context.is_tui() { + out_err!(context, "Request Id: {request_id}"); + } } - eprintln!() + context.emit_newline(); } } }; diff --git a/src/repl_helper.rs b/src/repl_helper.rs deleted file mode 100644 index a6e2c55..0000000 --- a/src/repl_helper.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::completion::SqlCompleter; -use crate::highlight::SqlHighlighter; -use rustyline::completion::{Completer, Pair}; -use rustyline::highlight::Highlighter; -use rustyline::hint::Hinter; -use rustyline::validate::Validator; -use rustyline::{Context, Helper}; -use std::borrow::Cow; - -/// REPL helper that integrates SqlHighlighter and SqlCompleter with rustyline -pub struct ReplHelper { - highlighter: SqlHighlighter, - completer: SqlCompleter, -} - -impl ReplHelper { - /// Create a new REPL helper with the given highlighter and completer - pub fn new(highlighter: SqlHighlighter, completer: SqlCompleter) -> Self { - Self { - highlighter, - completer, - } - } - - /// Get a mutable reference to the completer - pub fn completer_mut(&mut self) -> &mut SqlCompleter { - &mut self.completer - } -} - -// Implement the Helper trait (required) -impl Helper for ReplHelper {} - -// Delegate completion to SqlCompleter -impl Completer for ReplHelper { - type Candidate = Pair; - - fn complete( - &self, - line: &str, - pos: usize, - ctx: &Context<'_>, - ) -> rustyline::Result<(usize, Vec)> { - self.completer.complete(line, pos, ctx) - } -} - -// Implement empty Hinter (no hints for now) -impl Hinter for ReplHelper { - type Hint = String; -} - -// Implement empty Validator (accept all input) -impl Validator for ReplHelper {} - -// Delegate highlighting to SqlHighlighter -impl Highlighter for ReplHelper { - fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { - self.highlighter.highlight(line, pos) - } - - fn highlight_char(&self, line: &str, pos: usize) -> bool { - self.highlighter.highlight_char(line, pos) - } - - fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> { - // Don't highlight the prompt itself - Cow::Borrowed(prompt) - } - - fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { - // Don't highlight hints - Cow::Borrowed(hint) - } -} diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 4c30041..433aa03 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -1,3 +1,4 @@ +use crate::tui_msg::{TuiColor, TuiLine, TuiSpan}; use comfy_table::{Attribute, Cell, Color, ColumnConstraint, ContentArrangement, Table, Width as ComfyWidth}; use serde::Deserialize; use serde_json::Value; @@ -315,6 +316,430 @@ pub fn write_result_as_csv( Ok(()) } +// ── TUI-native table renderers ──────────────────────────────────────────────── +// These produce Vec directly, so the TUI can apply ratatui styles +// without any ANSI round-trip. + +/// Wrap a string (by char boundaries) into lines of at most `width` chars each, +/// padding every line to exactly `width` chars with spaces. +fn wrap_cell(s: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + let chars: Vec = s.chars().collect(); + if chars.is_empty() { + return vec![" ".repeat(width)]; + } + let mut lines = Vec::new(); + let mut start = 0; + while start < chars.len() { + let end = (start + width).min(chars.len()); + let slice: String = chars[start..end].iter().collect(); + let padded = format!("{: TuiLine { + let mut s = String::from(left); + for (i, &w) in col_widths.iter().enumerate() { + // fill repeated for col_width + 2 (padding on each side) + for _ in 0..w + 2 { + s.push_str(fill); + } + if i < col_widths.len() - 1 { + s.push_str(mid); + } + } + s.push_str(right); + TuiLine(vec![TuiSpan::plain(s)]) +} + +/// Decide column widths for horizontal layout. +/// Returns None if vertical format should be used instead. +fn decide_col_widths( + columns: &[ResultColumn], + formatted: &[Vec], + terminal_width: usize, + max_value_length: usize, +) -> Option> { + let n = columns.len(); + if n == 0 { + return Some(vec![]); + } + + // Overhead for N columns: 3*N + 1 (for "│ col │ col │" structure) + let overhead = 3 * n + 1; + let available = terminal_width.saturating_sub(overhead); + + // Single-column special case: always horizontal. + if n == 1 { + let effective_max = max_value_length.max(10_000); + let natural = { + let h = columns[0].name.chars().count(); + let d = formatted.iter().map(|r| r[0].chars().count()).max().unwrap_or(0); + h.max(d) + }; + // Cap natural width at (terminal_width * 4 / 5).max(10) + let cap = (terminal_width * 4 / 5).max(10); + let natural_capped = natural.min(cap); + let col_w = (terminal_width.saturating_sub(4)).max(1).min(natural_capped).max(1); + let _ = effective_max; // used conceptually for max_value_length + return Some(vec![col_w]); + } + + // Compute natural width for each column (capped at (terminal_width * 4/5).max(10)) + let cap = (terminal_width * 4 / 5).max(10); + let natural_widths: Vec = (0..n) + .map(|i| { + let h = columns[i].name.chars().count(); + let d = formatted.iter().map(|r| r[i].chars().count()).max().unwrap_or(0); + h.max(d).min(cap) + }) + .collect(); + + let sum_natural: usize = natural_widths.iter().sum(); + + // If sum of natural widths <= available: use horizontal with natural widths. + if sum_natural <= available { + return Some(natural_widths); + } + + // If available / N < 10: use vertical format. + if available / n < 10 { + return None; + } + + // Compute minimum width per column: + // - At least 10 + // - Header wraps at most once: ceil(header_len / 2) + // - Content not taller than wide: ceil(sqrt(max_content_len)) + let min_widths: Vec = (0..n) + .map(|i| { + let header_len = columns[i].name.chars().count(); + let max_content = formatted.iter().map(|r| r[i].chars().count()).max().unwrap_or(0); + let min_from_header = (header_len + 1) / 2; // ceil(header_len / 2) + let min_from_content = (max_content as f64).sqrt().ceil() as usize; + 10usize.max(min_from_header).max(min_from_content) + }) + .collect(); + + let sum_min: usize = min_widths.iter().sum(); + + // If sum of minimum widths > available: use vertical format. + if sum_min > available { + return None; + } + + // Distribute remaining space: start with min_widths, distribute extra space. + let mut col_widths = min_widths.clone(); + let mut remaining = available - sum_min; + + // Each pass adds 1 to each column that hasn't reached its natural width. + loop { + if remaining == 0 { + break; + } + let mut added = 0usize; + for i in 0..n { + if remaining == 0 { + break; + } + if col_widths[i] < natural_widths[i] { + col_widths[i] += 1; + remaining -= 1; + added += 1; + } + } + if added == 0 { + // All columns at natural width. + break; + } + } + + // Switch to vertical if any cell needs more wrap rows than there are columns. + // A tall narrow cell is a sign that vertical layout will be much more readable. + let any_cell_too_tall = formatted.iter().any(|row| { + (0..n).any(|i| { + let len = row[i].chars().count(); + let w = col_widths[i].max(1); + let rows_needed = (len + w - 1) / w; + rows_needed > n + }) + }); + if any_cell_too_tall { + return None; + } + + Some(col_widths) +} + +/// Render a horizontal table with given column widths as TUI lines. +/// Handles multi-line cell wrapping with box-drawing borders. +fn render_horizontal_tui( + columns: &[ResultColumn], + rows: &[Vec], + formatted: &[Vec], + col_widths: &[usize], +) -> Vec { + let n = columns.len(); + if n == 0 { + return vec![]; + } + + // Check if any cell (header or data) needs wrapping. + let any_wrap = { + let header_wraps = (0..n).any(|i| columns[i].name.chars().count() > col_widths[i]); + let data_wraps = formatted.iter().any(|row| (0..n).any(|i| row[i].chars().count() > col_widths[i])); + header_wraps || data_wraps + }; + + let mut lines = Vec::new(); + + // Top border: ┌──────┬──────┐ + lines.push(make_tui_border("┌", "─", "┬", "┐", col_widths)); + + // Header row (multi-line aware). + let header_cells: Vec> = (0..n) + .map(|i| wrap_cell(&columns[i].name, col_widths[i])) + .collect(); + let header_height = header_cells.iter().map(|c| c.len()).max().unwrap_or(1); + for line_idx in 0..header_height { + let mut spans: Vec = vec![TuiSpan::plain("│ ")]; + for (i, cell_lines) in header_cells.iter().enumerate() { + let text = cell_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(col_widths[i])); + spans.push(TuiSpan::styled(text, TuiColor::Cyan, true)); + if i < n - 1 { + spans.push(TuiSpan::plain(" │ ")); + } + } + spans.push(TuiSpan::plain(" │")); + lines.push(TuiLine(spans)); + } + + // Header separator: ╞═══════╪═══════╡ + lines.push(make_tui_border("╞", "═", "╪", "╡", col_widths)); + + // Data rows. + for (row_idx, row_cells) in formatted.iter().enumerate() { + let null_flags: Vec = (0..n) + .map(|i| rows.get(row_idx).and_then(|r| r.get(i)).map(|v| v.is_null()).unwrap_or(false)) + .collect(); + + let wrapped: Vec> = (0..n) + .map(|i| wrap_cell(&row_cells[i], col_widths[i])) + .collect(); + let row_height = wrapped.iter().map(|c| c.len()).max().unwrap_or(1); + + for line_idx in 0..row_height { + let mut spans: Vec = vec![TuiSpan::plain("│ ")]; + for (i, cell_lines) in wrapped.iter().enumerate() { + let text = cell_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(col_widths[i])); + if null_flags[i] && line_idx == 0 { + spans.push(TuiSpan::styled(text, TuiColor::DarkGray, false)); + } else if null_flags[i] { + // Padding lines for NULL cells also in dark gray + spans.push(TuiSpan::styled(text, TuiColor::DarkGray, false)); + } else { + spans.push(TuiSpan::plain(text)); + } + if i < n - 1 { + spans.push(TuiSpan::plain(" │ ")); + } + } + spans.push(TuiSpan::plain(" │")); + lines.push(TuiLine(spans)); + } + + // Row separator between data rows (only if any cell wraps). + if any_wrap && row_idx < formatted.len() - 1 { + lines.push(make_tui_border("├", "─", "┼", "┤", col_widths)); + } + } + + // Bottom border: └──────┴──────┘ + lines.push(make_tui_border("└", "─", "┴", "┘", col_widths)); + + lines +} + +/// Truncate a formatted value to at most `max_len` chars (char count), appending "..." if truncated. +fn truncate_to_chars(s: String, max_len: usize) -> String { + let char_count = s.chars().count(); + if char_count > max_len { + let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect(); + format!("{}...", truncated) + } else { + s + } +} + +/// Format and truncate a cell value, using char-count for length measurement. +/// Control characters (newlines, tabs, carriage returns) are replaced with spaces +/// so that wrap_cell produces clean fixed-width chunks that ratatui renders correctly. +fn fmt_cell(val: &Value, max_value_length: usize) -> String { + let s = format_value(val); + // Replace control characters that would break ratatui's line rendering. + let s = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect::(); + truncate_to_chars(s, max_value_length) +} + +/// Render a table in auto mode (horizontal if it fits, vertical otherwise). +/// Uses smart column width computation and box-drawing borders. +pub fn render_table_to_tui_lines( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> Vec { + if columns.is_empty() { + return vec![]; + } + let n = columns.len(); + let tw = terminal_width as usize; + + // For single-column tables, use a larger max to show more content. + let effective_max = if n == 1 { max_value_length.max(10_000) } else { max_value_length }; + + // Format all cell values using char-count for truncation. + let formatted: Vec> = rows + .iter() + .map(|row| (0..n).map(|i| fmt_cell(row.get(i).unwrap_or(&Value::Null), effective_max)).collect()) + .collect(); + + match decide_col_widths(columns, &formatted, tw, effective_max) { + Some(col_widths) => render_horizontal_tui(columns, rows, &formatted, &col_widths), + None => render_vertical_table_to_tui_lines(columns, rows, terminal_width, max_value_length), + } +} + +/// Render a vertical (two-column: name | value) table as TUI lines. +/// For each original row, renders a mini 2-column table with column names and values. +pub fn render_vertical_table_to_tui_lines( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> Vec { + let tw = terminal_width as usize; + + // Name column width: max of all column name lengths, clamped to [10, 30]. + let max_name_len = columns.iter().map(|c| c.name.chars().count()).max().unwrap_or(0); + let name_col_w = max_name_len.min(30).max(10); + + // Value column width: remaining space minus overhead (2 cols = 3*2+1 = 7 overhead). + let val_col_w = tw.saturating_sub(name_col_w + 7).max(10); + + let col_widths = [name_col_w, val_col_w]; + + let mut lines = Vec::new(); + + for (row_idx, row) in rows.iter().enumerate() { + // "Row N:" label (cyan + bold) as a standalone TuiLine. + lines.push(TuiLine(vec![TuiSpan::styled( + format!("Row {}:", row_idx + 1), + TuiColor::Cyan, + true, + )])); + + // Check if any name or value wraps within this row's mini-table. + let any_wrap_in_row = columns.iter().enumerate().any(|(col_idx, col)| { + let name_wraps = col.name.chars().count() > name_col_w; + let val = fmt_cell(row.get(col_idx).unwrap_or(&Value::Null), max_value_length); + let val_wraps = val.chars().count() > val_col_w; + name_wraps || val_wraps + }); + + // Top border of mini-table. + lines.push(make_tui_border("┌", "─", "┬", "┐", &col_widths)); + + for (field_idx, col) in columns.iter().enumerate() { + let is_null = row.get(field_idx).map(|v| v.is_null()).unwrap_or(false); + let val = fmt_cell(row.get(field_idx).unwrap_or(&Value::Null), max_value_length); + + let name_lines = wrap_cell(&col.name, name_col_w); + let val_lines = wrap_cell(&val, val_col_w); + let field_height = name_lines.len().max(val_lines.len()); + + for line_idx in 0..field_height { + let name_text = name_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(name_col_w)); + let val_text = val_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(val_col_w)); + + let mut spans: Vec = vec![TuiSpan::plain("│ ")]; + spans.push(TuiSpan::styled(name_text, TuiColor::Cyan, true)); + spans.push(TuiSpan::plain(" │ ")); + if is_null { + spans.push(TuiSpan::styled(val_text, TuiColor::DarkGray, false)); + } else { + spans.push(TuiSpan::plain(val_text)); + } + spans.push(TuiSpan::plain(" │")); + lines.push(TuiLine(spans)); + } + + // Row separator between fields (only if any name or value wraps). + if any_wrap_in_row && field_idx < columns.len() - 1 { + lines.push(make_tui_border("├", "─", "┼", "┤", &col_widths)); + } + } + + // Bottom border of mini-table. + lines.push(make_tui_border("└", "─", "┴", "┘", &col_widths)); + + // Blank line between rows (except after last). + if row_idx < rows.len() - 1 { + lines.push(TuiLine(vec![TuiSpan::plain(String::new())])); + } + } + + lines +} + +/// Render a table always in horizontal format (forced horizontal). +/// If column widths would normally cause vertical layout, falls back to equal-share widths. +pub fn render_horizontal_forced_tui_lines( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> Vec { + if columns.is_empty() { + return vec![]; + } + let n = columns.len(); + let tw = terminal_width as usize; + + let effective_max = if n == 1 { max_value_length.max(10_000) } else { max_value_length }; + + let formatted: Vec> = rows + .iter() + .map(|row| (0..n).map(|i| fmt_cell(row.get(i).unwrap_or(&Value::Null), effective_max)).collect()) + .collect(); + + let col_widths = match decide_col_widths(columns, &formatted, tw, effective_max) { + Some(widths) => widths, + None => { + // Fall back to equal-share column widths. + let overhead = 3 * n + 1; + let available = tw.saturating_sub(overhead); + let equal_w = (available / n).max(1); + vec![equal_w; n] + } + }; + + render_horizontal_tui(columns, rows, &formatted, &col_widths) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/history.rs b/src/tui/history.rs new file mode 100644 index 0000000..83e922d --- /dev/null +++ b/src/tui/history.rs @@ -0,0 +1,161 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +/// File-backed history with cursor-based navigation. +/// Entries are stored one per line; embedded newlines are escaped as `\n`. +pub struct History { + entries: Vec, + /// None = not currently navigating. Some(i) = showing entries[i]. + cursor: Option, + /// The text that was in the editor when navigation started. + saved_current: String, + path: PathBuf, +} + +impl History { + pub fn new(path: PathBuf) -> Self { + Self { + entries: Vec::new(), + cursor: None, + saved_current: String::new(), + path, + } + } + + /// Load history from disk. Each line is one entry; `\n` in entries is stored as `\\n`. + pub fn load(&mut self) -> Result<(), Box> { + if !self.path.exists() { + return Ok(()); + } + let content = fs::read_to_string(&self.path)?; + self.entries = content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.replace("\\n", "\n")) + .collect(); + // Keep at most 10 000 entries (trim oldest) + if self.entries.len() > 10_000 { + let start = self.entries.len() - 10_000; + self.entries = self.entries[start..].to_vec(); + } + Ok(()) + } + + /// Append a single entry to the history file without rewriting the whole file. + fn append_to_file(&self, entry: &str) -> Result<(), Box> { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + let stored = entry.replace('\n', "\\n"); + writeln!(file, "{}", stored)?; + Ok(()) + } + + /// Add a new entry. Deduplicates consecutive identical entries. + pub fn add(&mut self, entry: String) { + if entry.trim().is_empty() { + return; + } + if self.entries.last().map(|e| e == &entry).unwrap_or(false) { + self.cursor = None; + return; + } + self.entries.push(entry.clone()); + if self.entries.len() > 10_000 { + self.entries.remove(0); + } + self.cursor = None; + let _ = self.append_to_file(&entry); + } + + /// Navigate backward (older). Returns the entry to display, or `None` if history is empty. + /// `current` is the text currently in the editor (saved so we can restore it on forward). + pub fn go_back(&mut self, current: &str) -> Option { + if self.entries.is_empty() { + return None; + } + match self.cursor { + None => { + self.saved_current = current.to_string(); + self.cursor = Some(self.entries.len() - 1); + } + Some(0) => {} // already at oldest – stay + Some(i) => { + self.cursor = Some(i - 1); + } + } + self.cursor.map(|i| self.entries[i].clone()) + } + + /// Navigate forward (newer). Returns the entry to display, or the saved current text + /// when we return past the newest entry. + pub fn go_forward(&mut self) -> Option { + match self.cursor { + None => None, + Some(i) if i + 1 >= self.entries.len() => { + self.cursor = None; + Some(self.saved_current.clone()) + } + Some(i) => { + self.cursor = Some(i + 1); + Some(self.entries[i + 1].clone()) + } + } + } + + /// Reset navigation state (called when user types something new). + pub fn reset_navigation(&mut self) { + self.cursor = None; + self.saved_current.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn dummy() -> History { + History::new(PathBuf::from("/tmp/test_fb_history_dummy")) + } + + #[test] + fn test_add_and_navigate() { + let mut h = dummy(); + h.add("SELECT 1;".to_string()); + h.add("SELECT 2;".to_string()); + h.add("SELECT 3;".to_string()); + + assert_eq!(h.go_back(""), Some("SELECT 3;".to_string())); + assert_eq!(h.go_back(""), Some("SELECT 2;".to_string())); + assert_eq!(h.go_back(""), Some("SELECT 1;".to_string())); + // Already at oldest – stays + assert_eq!(h.go_back(""), Some("SELECT 1;".to_string())); + + // Forward brings us back + assert_eq!(h.go_forward(), Some("SELECT 2;".to_string())); + assert_eq!(h.go_forward(), Some("SELECT 3;".to_string())); + // Past newest – returns saved current + assert_eq!(h.go_forward(), Some("".to_string())); + } + + #[test] + fn test_dedup_consecutive() { + let mut h = dummy(); + h.add("SELECT 1;".to_string()); + h.add("SELECT 1;".to_string()); + assert_eq!(h.entries.len(), 1); + } + + #[test] + fn test_reset_navigation() { + let mut h = dummy(); + h.add("a".to_string()); + h.add("b".to_string()); + h.go_back("current"); + h.reset_navigation(); + assert!(h.cursor.is_none()); + } +} diff --git a/src/tui/layout.rs b/src/tui/layout.rs new file mode 100644 index 0000000..4302dfd --- /dev/null +++ b/src/tui/layout.rs @@ -0,0 +1,49 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// The three panes of the TUI. +pub struct AppLayout { + pub output: Rect, + pub input: Rect, + pub status: Rect, +} + +/// Rows always reserved at the bottom for the input area (5 content + 2 borders). +/// The output pane never encroaches into this space, even when the textarea is +/// only one line tall. When the textarea grows past 5 lines it starts to push +/// the output pane upward. +const RESERVED_INPUT: u16 = 5; + +/// Compute a 4-pane vertical layout: output / spacer / input / status. +/// +/// `input_height` is the *actual* (tight) height of the input pane including +/// its borders. A transparent spacer is inserted above the input pane so that +/// the bottom `RESERVED_INPUT` rows are always reserved, keeping the output +/// pane stable while the textarea is small. +pub fn compute_layout(area: Rect, input_height: u16) -> AppLayout { + const STATUS_HEIGHT: u16 = 1; + + // Hard ceiling: never let the input+spacer eat more than area/3 + let max_input = area.height.saturating_sub(STATUS_HEIGHT + 3); + let input_h = input_height.clamp(3, max_input.max(3)); + + // How many rows are reserved at the bottom (at least RESERVED_INPUT, but + // clamped so the output pane always keeps at least 3 rows). + let reserved = RESERVED_INPUT.max(input_h).min(max_input.max(input_h)); + let spacer = reserved.saturating_sub(input_h); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // output pane – takes remaining space + Constraint::Length(input_h), // input pane (anchored at top of reserved area) + Constraint::Length(spacer), // transparent spacer below input + Constraint::Length(STATUS_HEIGHT), // status bar + ]) + .split(area); + + AppLayout { + output: chunks[0], + input: chunks[1], + status: chunks[3], + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..4dd2353 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,585 @@ +pub mod history; +pub mod layout; +pub mod output_pane; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + + +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyboardEnhancementFlags, + KeyModifiers, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Terminal, +}; +use crate::tui_msg::TuiMsg; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tui_textarea::{CursorMove, Input, TextArea}; + +use crate::completion::schema_cache::SchemaCache; +use crate::completion::usage_tracker::UsageTracker; +use crate::completion::SqlCompleter; +use crate::context::Context; +use crate::meta_commands::handle_meta_command; +use crate::query::{query, try_split_queries}; +use crate::viewer::open_csvlens_viewer; +use crate::CLI_VERSION; + +use history::History; +use layout::compute_layout; +use output_pane::OutputPane; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub struct TuiApp { + context: Context, + textarea: TextArea<'static>, + output: OutputPane, + history: History, + schema_cache: Arc, + completer: SqlCompleter, + + // Query execution state + query_rx: Option>, + cancel_token: Option, + is_running: bool, + query_start: Option, + spinner_tick: u64, + running_hint: String, // e.g. "Showing first N rows — collecting remainder..." + + // After leaving alt-screen for csvlens we need a full redraw + needs_clear: bool, + should_quit: bool, + pub has_error: bool, +} + +impl TuiApp { + pub fn new(context: Context, schema_cache: Arc) -> Self { + let usage_tracker = context + .usage_tracker + .clone() + .unwrap_or_else(|| Arc::new(UsageTracker::new(10))); + let completer = + SqlCompleter::new(schema_cache.clone(), usage_tracker, !context.args.no_completion); + + let history_path = crate::utils::history_path().unwrap_or_default(); + let mut history = History::new(history_path); + let _ = history.load(); + + let textarea = Self::make_textarea(); + + Self { + context, + textarea, + output: OutputPane::new(), + history, + schema_cache, + completer, + query_rx: None, + cancel_token: None, + is_running: false, + query_start: None, + spinner_tick: 0, + running_hint: String::new(), + needs_clear: false, + should_quit: false, + has_error: false, + } + } + + fn make_textarea() -> TextArea<'static> { + TextArea::default() + } + + // ── Public entry point ─────────────────────────────────────────────────── + + pub async fn run(mut self) -> Result> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + // Enable Kitty keyboard protocol so Shift+Enter is distinguishable from Enter. + // Silently ignore on terminals that don't support it. + let _ = execute!( + stdout, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Kick off background schema cache refresh + if !self.context.args.no_completion { + let cache = self.schema_cache.clone(); + let mut ctx_clone = self.context.clone(); + tokio::spawn(async move { + if let Err(_e) = cache.refresh(&mut ctx_clone).await { + // silently ignore – completion just won't work + } + }); + } + + let result = self.event_loop(&mut terminal).await; + + disable_raw_mode()?; + let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + + result + } + + // ── Event loop ─────────────────────────────────────────────────────────── + + async fn event_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result> { + loop { + self.drain_query_output(); + + if self.is_running { + self.spinner_tick += 1; + } + + if self.needs_clear { + terminal.clear()?; + self.needs_clear = false; + } + + terminal.draw(|f| self.render(f))?; + + // Poll with a short timeout so the spinner animates even without input + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) => { + if self.handle_key(key).await { + break; + } + } + + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => self.output.scroll_up(8), + MouseEventKind::ScrollDown => self.output.scroll_down(8), + _ => {} // ignore clicks — Shift+drag still works for terminal selection + }, + Event::Resize(_, _) => {} // redraw on next tick + _ => {} + } + } + + if self.should_quit { + break; + } + } + + Ok(self.has_error) + } + + // ── Query output draining ──────────────────────────────────────────────── + + fn drain_query_output(&mut self) { + let rx = match &mut self.query_rx { + Some(r) => r, + None => return, + }; + + loop { + match rx.try_recv() { + Ok(TuiMsg::StyledLines(lines)) => { + self.output.push_tui_lines(lines); + } + Ok(TuiMsg::Line(line)) => { + // Capture the "Showing first N rows..." hint for the running pane only. + if self.is_running && line.starts_with("Showing first ") { + self.running_hint = line; + continue; + } + if line.starts_with("Time: ") || line.starts_with("Scanned: ") { + self.output.push_stat(&line); + } else if line.starts_with("Error: ") || line.starts_with("^C") { + self.output.push_error(&line); + } else { + self.output.push_ansi_text(&line); + } + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + // Query task finished + self.is_running = false; + self.running_hint.clear(); + self.cancel_token = None; + self.query_rx = None; + break; + } + } + } + } + + // ── Key handling ───────────────────────────────────────────────────────── + + /// Returns `true` when the app should exit. + async fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + // While a query is running only Ctrl+C is accepted (to cancel) + if self.is_running { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + if let Some(token) = &self.cancel_token { + token.cancel(); + } + } + return false; + } + + match (key.code, key.modifiers) { + // ── Exit ────────────────────────────────────────────────────── + (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return true; + } + + // ── Cancel / clear ──────────────────────────────────────────── + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + self.reset_textarea(); + self.output.push_line("^C"); + self.history.reset_navigation(); + } + + // ── Ctrl+V: open csvlens ────────────────────────────────────── + (KeyCode::Char('v'), m) if m.contains(KeyModifiers::CONTROL) => { + self.open_viewer(); + } + + // ── Shift+Enter: always insert newline ─────────────────────── + (KeyCode::Enter, m) if m.contains(KeyModifiers::SHIFT) => { + self.textarea.insert_newline(); + } + + // ── Submit on Enter ─────────────────────────────────────────── + (KeyCode::Enter, m) if !m.contains(KeyModifiers::ALT) => { + let text = self.textarea.lines().join("\n"); + let trimmed = text.trim().to_string(); + + if trimmed.is_empty() { + return false; + } + + // Backslash meta-commands + if trimmed.starts_with('\\') { + self.handle_backslash_command(&trimmed).await; + self.reset_textarea(); + return false; + } + + if trimmed == "quit" || trimmed == "exit" { + self.should_quit = true; + return true; + } + + if let Some(queries) = try_split_queries(&trimmed) { + self.history.add(trimmed.clone()); + self.history.reset_navigation(); + self.execute_queries(trimmed, queries).await; + self.reset_textarea(); + } else { + // Query not complete yet – add newline + self.textarea.insert_newline(); + } + } + + // ── History: Up/Down at buffer boundaries; Ctrl+Up/Down always ── + (KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { + let current = self.textarea.lines().join("\n"); + if let Some(entry) = self.history.go_back(¤t) { + self.set_textarea_content(&entry); + } + } + (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { + if let Some(entry) = self.history.go_forward() { + self.set_textarea_content(&entry); + } + } + (KeyCode::Up, _) => { + let (row, _) = self.textarea.cursor(); + if row == 0 { + let current = self.textarea.lines().join("\n"); + if let Some(entry) = self.history.go_back(¤t) { + self.set_textarea_content(&entry); + } + } else { + // Move cursor within the buffer; keep history navigation active so + // pressing Up again after reaching row 0 continues going back. + self.textarea.input(Input::from(key)); + } + } + (KeyCode::Down, _) => { + let (row, _) = self.textarea.cursor(); + let last_row = self.textarea.lines().len().saturating_sub(1); + if row >= last_row { + if let Some(entry) = self.history.go_forward() { + self.set_textarea_content(&entry); + } + } else { + // Move cursor within the buffer; keep history navigation active. + self.textarea.input(Input::from(key)); + } + } + + // ── Scroll output pane ──────────────────────────────────────── + (KeyCode::PageUp, _) => { + self.output.scroll_up(10); + } + (KeyCode::PageDown, _) => { + self.output.scroll_down(10); + } + + // ── All other keys → textarea ───────────────────────────────── + _ => { + let input = Input::from(key); + self.textarea.input(input); + self.history.reset_navigation(); + } + } + + false + } + + // ── Backslash commands ─────────────────────────────────────────────────── + + async fn handle_backslash_command(&mut self, cmd: &str) { + match cmd { + "\\view" => self.open_viewer(), + "\\refresh" | "\\refresh_cache" => self.do_refresh(), + "\\help" => self.show_help(), + _ => { + match handle_meta_command(&mut self.context, cmd) { + Ok(true) => {} + Ok(false) => self + .output + .push_line(format!("Unknown command: {}", cmd)), + Err(e) => self.output.push_line(format!("Error: {}", e)), + } + self.history.add(cmd.to_string()); + } + } + } + + fn open_viewer(&mut self) { + if self.context.last_result.is_none() { + self.output + .push_line("No query results to display. Run a query first."); + return; + } + + // Temporarily leave the TUI to let csvlens take over the terminal. + let _ = disable_raw_mode(); + let _ = execute!(std::io::stdout(), LeaveAlternateScreen); + + if let Err(e) = open_csvlens_viewer(&self.context) { + eprintln!("Error: {}", e); + } + + let _ = enable_raw_mode(); + let _ = execute!(std::io::stdout(), EnterAlternateScreen); + self.needs_clear = true; + } + + fn do_refresh(&mut self) { + if self.context.args.no_completion { + self.output + .push_line("Auto-completion is disabled. Enable with: set completion = on;"); + return; + } + let cache = self.schema_cache.clone(); + let mut ctx_clone = self.context.clone(); + self.output.push_line("Refreshing schema cache..."); + tokio::spawn(async move { + if let Err(e) = cache.refresh(&mut ctx_clone).await { + eprintln!("Failed to refresh schema cache: {}", e); + } + }); + } + + fn show_help(&mut self) { + self.output.push_ansi_text( + "Special commands:\n\ + \\view - Open last query result in csvlens viewer\n\ + \\refresh - Manually refresh schema cache\n\ + \\help - Show this help message\n\ + \n\ + SQL-style commands:\n\ + set format = ; - Change output format\n\ + unset format; - Reset format to default\n\ + set completion = on/off; - Enable/disable auto-completion\n\ + \n\ + Keyboard shortcuts:\n\ + Ctrl+V - Open csvlens viewer for last result\n\ + Ctrl+D - Exit\n\ + Ctrl+C - Cancel current input (or running query)\n\ + Ctrl+Up/Down - Cycle history\n\ + Page Up/Down - Scroll output pane\n\ + Enter - Submit query (when complete) or insert newline", + ); + } + + // ── Query execution ────────────────────────────────────────────────────── + + async fn execute_queries(&mut self, original_text: String, queries: Vec) { + // Echo query to output pane with ❯ prompt, one visual line per SQL line + let mut lines = original_text.trim().lines(); + if let Some(first) = lines.next() { + self.output.push_prompt(format!("❯ {}", first)); + for line in lines { + self.output.push_prompt(format!(" {}", line)); + } + } + + let (tx, rx) = mpsc::unbounded_channel::(); + let cancel_token = CancellationToken::new(); + + self.query_rx = Some(rx); + self.cancel_token = Some(cancel_token.clone()); + self.is_running = true; + self.running_hint.clear(); + self.query_start = Some(Instant::now()); + + // Build a context clone with the TUI output channel attached + let mut ctx = self.context.clone(); + ctx.tui_output_tx = Some(tx); + ctx.query_cancel = Some(cancel_token); + + // Sync completer enabled state + self.completer.set_enabled(!self.context.args.no_completion); + + tokio::spawn(async move { + for q in queries { + if query(&mut ctx, q).await.is_err() { + // Error message already sent through the channel by query() + return; + } + } + // `ctx` is dropped here → `tui_output_tx` is dropped → channel disconnected + }); + } + + // ── Textarea helpers ───────────────────────────────────────────────────── + + fn reset_textarea(&mut self) { + self.textarea = Self::make_textarea(); + } + + fn set_textarea_content(&mut self, content: &str) { + let lines: Vec = content.lines().map(|l| l.to_string()).collect(); + self.textarea = TextArea::new(if lines.is_empty() { + vec![String::new()] + } else { + lines + }); + // Move cursor to end of content + self.textarea.move_cursor(CursorMove::Bottom); + self.textarea.move_cursor(CursorMove::End); + } + + // ── Rendering ──────────────────────────────────────────────────────────── + + fn render(&mut self, f: &mut ratatui::Frame) { + let area = f.area(); + + let input_height = if self.is_running { + // Running pane: spinner row + hint row + top/bottom borders = 4 + 4u16 + } else { + // Input pane: textarea lines + 2 border rows, at least 3 + let ta_lines = self.textarea.lines().len() as u16; + (ta_lines + 2).clamp(3, area.height / 3) + }; + + let layout = compute_layout(area, input_height); + + // Clamp scroll so it stays in bounds + self.output.clamp_scroll(layout.output.height); + + // Output pane + self.output.render(layout.output, f.buffer_mut()); + + if self.is_running { + self.render_running_pane(f, layout.input); + } else { + // Input area: outer block provides top + bottom separator lines + let outer_block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = outer_block.inner(layout.input); + f.render_widget(outer_block, layout.input); + + // Split inner area: prompt column + textarea + let chunks = Layout::horizontal([ + Constraint::Length(2), // "❯ " prompt + Constraint::Min(0), + ]) + .split(inner); + + let prompt = Paragraph::new("❯").style(Style::default().fg(Color::Green)); + f.render_widget(prompt, chunks[0]); + f.render_widget(&self.textarea, chunks[1]); + } + + // Status bar + self.render_status_bar(f, layout.status); + } + + fn render_running_pane(&self, f: &mut ratatui::Frame, area: Rect) { + let outer_block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = outer_block.inner(area); + f.render_widget(outer_block, area); + + let spinner = SPINNER_FRAMES[(self.spinner_tick / 2) as usize % SPINNER_FRAMES.len()]; + let elapsed = if let Some(start) = self.query_start { + format!("{} {:.1}s", spinner, start.elapsed().as_secs_f64()) + } else { + spinner.to_string() + }; + + let mut lines = vec![ + Line::from(Span::styled(elapsed, Style::default().fg(Color::Yellow))), + ]; + if !self.running_hint.is_empty() { + lines.push(Line::from(Span::styled( + self.running_hint.clone(), + Style::default().fg(Color::DarkGray), + ))); + } + + f.render_widget(Paragraph::new(lines), inner); + } + + fn render_status_bar(&self, f: &mut ratatui::Frame, area: Rect) { + let host = &self.context.args.host; + let db = &self.context.args.database; + + let left = format!(" {} | {} | v{}", host, db, CLI_VERSION); + let right = if self.is_running { + " Ctrl+C cancel ".to_string() + } else { + " Ctrl+D exit Ctrl+H help ".to_string() + }; + + // Pad between left and right + let total = area.width as usize; + let pad = total.saturating_sub(left.len() + right.len()); + let status_text = format!("{}{}{}", left, " ".repeat(pad), right); + + let status = Paragraph::new(status_text) + .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + f.render_widget(status, area); + } +} diff --git a/src/tui/output_pane.rs b/src/tui/output_pane.rs new file mode 100644 index 0000000..1807b71 --- /dev/null +++ b/src/tui/output_pane.rs @@ -0,0 +1,250 @@ +use crate::tui_msg::{TuiColor, TuiLine, TuiSpan}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; + +// ── OutputLine ──────────────────────────────────────────────────────────────── + +/// A single pre-rendered line in the output pane. +pub struct OutputLine { + content: Line<'static>, +} + +impl OutputLine { + fn from_line(content: Line<'static>) -> Self { + Self { content } + } +} + +// ── OutputPane ──────────────────────────────────────────────────────────────── + +/// Scrollable output pane that accumulates lines from query results. +pub struct OutputPane { + lines: Vec, + /// Scroll offset in lines. `usize::MAX` is a sentinel meaning "scroll to bottom", + /// resolved to the correct value by `clamp_scroll` before each render. + scroll: usize, +} + +impl OutputPane { + pub fn new() -> Self { + Self { lines: Vec::new(), scroll: 0 } + } + + /// Push text that may contain ANSI SGR escape codes (e.g. from comfy-table). + /// Splits on `\n` and parses each sub-line into styled spans. + pub fn push_ansi_text(&mut self, text: &str) { + for line in text.split('\n') { + self.lines.push(OutputLine::from_line(parse_ansi_line(line))); + } + self.scroll_to_bottom(); + } + + /// Push a plain unstyled line. + pub fn push_line(&mut self, line: impl Into) { + let s: String = line.into(); + self.lines.push(OutputLine::from_line(Line::from(s))); + self.scroll_to_bottom(); + } + + /// Push the echoed prompt line (`❯ SELECT …`). + /// The `❯ ` prefix is rendered in green+bold; the SQL text in yellow. + /// Continuation lines (indented with spaces) are rendered fully in yellow. + pub fn push_prompt(&mut self, line: impl Into) { + let s: String = line.into(); + let ratatui_line = if let Some(sql) = s.strip_prefix("❯ ") { + Line::from(vec![ + Span::styled("❯ ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::styled(sql.to_string(), Style::default().fg(Color::Yellow)), + ]) + } else { + Line::from(Span::styled(s, Style::default().fg(Color::Yellow))) + }; + self.lines.push(OutputLine::from_line(ratatui_line)); + self.scroll_to_bottom(); + } + + /// Push a timing / scan-statistics line — dark gray. + pub fn push_stat(&mut self, text: &str) { + for line in text.split('\n') { + let span = Span::styled( + line.to_string(), + Style::default().fg(Color::DarkGray), + ); + self.lines.push(OutputLine::from_line(Line::from(span))); + } + self.scroll_to_bottom(); + } + + /// Push pre-styled lines produced by the TUI table renderer. + pub fn push_tui_lines(&mut self, lines: Vec) { + for tui_line in lines { + let spans: Vec> = tui_line.0.into_iter().map(tui_span_to_ratatui).collect(); + self.lines.push(OutputLine::from_line(Line::from(spans))); + } + self.scroll_to_bottom(); + } + + /// Push an error line — red. + pub fn push_error(&mut self, text: &str) { + for line in text.split('\n') { + let span = Span::styled(line.to_string(), Style::default().fg(Color::Red)); + self.lines.push(OutputLine::from_line(Line::from(span))); + } + self.scroll_to_bottom(); + } + + pub fn scroll_up(&mut self, amount: u16) { + self.scroll = self.scroll.saturating_sub(amount as usize); + } + + pub fn scroll_down(&mut self, amount: u16) { + self.scroll = self.scroll.saturating_add(amount as usize); + } + + fn scroll_to_bottom(&mut self) { + self.scroll = usize::MAX; // sentinel — resolved in clamp_scroll + } + + /// Clamp scroll so we don't go past the end of content. + pub fn clamp_scroll(&mut self, visible_height: u16) { + let total = self.lines.len(); + let height = visible_height as usize; + if total <= height { + self.scroll = 0; + } else if self.scroll == usize::MAX || self.scroll > total - height { + self.scroll = total - height; + } + // else: user-scrolled position is still in range, leave it alone + } + + /// Render only the visible slice of lines — O(visible_height), not O(total). + /// Content is bottom-anchored: empty padding fills the top so output + /// grows upward from the input area, like a normal terminal. + pub fn render(&self, area: Rect, buf: &mut Buffer) { + let start = self.scroll; + let end = (start + area.height as usize).min(self.lines.len()); + let height = area.height as usize; + + let content: Vec = self.lines[start..end] + .iter() + .map(|l| l.content.clone()) + .collect(); + + // Pad with blank lines above so content sits at the bottom. + let padding = height.saturating_sub(content.len()); + let visible: Vec = std::iter::repeat_n(Line::raw(""), padding) + .chain(content) + .collect(); + + Widget::render(Paragraph::new(visible), area, buf); + } +} + +// ── TuiSpan → ratatui Span conversion ──────────────────────────────────────── + +fn tui_span_to_ratatui(span: TuiSpan) -> Span<'static> { + let color = match span.color { + TuiColor::Default => Color::Reset, + TuiColor::Cyan => Color::Cyan, + TuiColor::DarkGray => Color::DarkGray, + }; + let mut style = Style::default().fg(color); + if span.bold { + style = style.add_modifier(Modifier::BOLD); + } + Span::styled(span.text, style) +} + +// ── ANSI SGR parser ─────────────────────────────────────────────────────────── + +/// Parse a string that may contain ANSI SGR escape sequences (`\x1b[...m`) into +/// a ratatui `Line` of styled spans. Unrecognised codes are silently ignored. +fn parse_ansi_line(text: &str) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + let mut current_style = Style::default(); + let bytes = text.as_bytes(); + let mut seg_start = 0; // byte index of start of current unstyled segment + let mut i = 0; + + while i < bytes.len() { + // Look for ESC [ + if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // Flush the text segment before this escape sequence. + if seg_start < i { + spans.push(Span::styled(text[seg_start..i].to_string(), current_style)); + } + // Scan forward to the terminating letter. + let param_start = i + 2; + let mut j = param_start; + while j < bytes.len() && !bytes[j].is_ascii_alphabetic() { + j += 1; + } + if j < bytes.len() && bytes[j] == b'm' { + // SGR sequence — update style. + let params = &text[param_start..j]; + current_style = apply_sgr(current_style, params); + i = j + 1; + } else { + // Non-SGR or malformed — skip the ESC byte and retry. + i += 1; + } + seg_start = i; + } else { + i += 1; + } + } + + // Flush any remaining text. + if seg_start < text.len() { + spans.push(Span::styled(text[seg_start..].to_string(), current_style)); + } + + Line::from(spans) +} + +/// Apply a semicolon-separated list of SGR parameters to `style`. +fn apply_sgr(style: Style, params: &str) -> Style { + // Empty params or "0" alone means reset. + if params.is_empty() || params == "0" { + return Style::default(); + } + let mut s = style; + for param in params.split(';') { + s = match param { + "0" => Style::default(), + "1" => s.add_modifier(Modifier::BOLD), + "2" => s.add_modifier(Modifier::DIM), + "3" => s.add_modifier(Modifier::ITALIC), + "4" => s.add_modifier(Modifier::UNDERLINED), + "22" => s.remove_modifier(Modifier::BOLD), + "23" => s.remove_modifier(Modifier::ITALIC), + "24" => s.remove_modifier(Modifier::UNDERLINED), + // Standard foreground colours + "30" => s.fg(Color::Black), + "31" => s.fg(Color::Red), + "32" => s.fg(Color::Green), + "33" => s.fg(Color::Yellow), + "34" => s.fg(Color::Blue), + "35" => s.fg(Color::Magenta), + "36" => s.fg(Color::Cyan), + "37" => s.fg(Color::White), + "39" => s.fg(Color::Reset), + // Bright foreground colours + "90" => s.fg(Color::DarkGray), + "91" => s.fg(Color::LightRed), + "92" => s.fg(Color::LightGreen), + "93" => s.fg(Color::LightYellow), + "94" => s.fg(Color::LightBlue), + "95" => s.fg(Color::LightMagenta), + "96" => s.fg(Color::LightCyan), + "97" => s.fg(Color::White), + _ => s, // ignore unrecognised params + }; + } + s +} diff --git a/src/tui_msg.rs b/src/tui_msg.rs new file mode 100644 index 0000000..fcd9537 --- /dev/null +++ b/src/tui_msg.rs @@ -0,0 +1,33 @@ +/// Messages sent from the query thread to the TUI output pane via the mpsc channel. +pub enum TuiMsg { + /// A plain text line (may contain ANSI escape codes). + Line(String), + /// Pre-styled lines produced by the custom TUI table renderer. + StyledLines(Vec), +} + +/// A single rendered line made up of zero or more styled spans. +pub struct TuiLine(pub Vec); + +/// A styled run of text. +pub struct TuiSpan { + pub text: String, + pub color: TuiColor, + pub bold: bool, +} + +impl TuiSpan { + pub fn plain(text: impl Into) -> Self { + Self { text: text.into(), color: TuiColor::Default, bold: false } + } + pub fn styled(text: impl Into, color: TuiColor, bold: bool) -> Self { + Self { text: text.into(), color, bold } + } +} + +#[derive(Clone, Copy)] +pub enum TuiColor { + Default, + Cyan, + DarkGray, +} From 1501a4a132da91bf60a341d4f3a5fc4c6bd95875 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 16:51:04 +0100 Subject: [PATCH 028/147] Add Ctrl+R reverse history search (Phase 2) Implements readline-style incremental reverse search over history: - Ctrl+R activates search mode; subsequent presses cycle to older matches - Characters appended to the search query narrow the match in real time - Backspace removes the last search character and re-searches from most-recent - Enter accepts the current match into the textarea - Escape / Ctrl+G restores the textarea to its pre-search content - Any other key (arrows, etc.) accepts the match then re-dispatches normally The input pane is replaced by a two-line search overlay with a cyan border: (reverse-i-search)`query': Status bar shows contextual hint: "Enter accept Ctrl+R older Esc cancel" while search is active. Search is case-insensitive substring match walking from most-recent entry backward; HistorySearch struct lives in src/tui/history_search.rs. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/history.rs | 5 ++ src/tui/history_search.rs | 144 ++++++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 125 ++++++++++++++++++++++++++++++++- tests/cli.rs | 6 +- 4 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 src/tui/history_search.rs diff --git a/src/tui/history.rs b/src/tui/history.rs index 83e922d..7ae6726 100644 --- a/src/tui/history.rs +++ b/src/tui/history.rs @@ -70,6 +70,11 @@ impl History { let _ = self.append_to_file(&entry); } + /// Read-only view of all history entries (oldest first). + pub fn entries(&self) -> &[String] { + &self.entries + } + /// Navigate backward (older). Returns the entry to display, or `None` if history is empty. /// `current` is the text currently in the editor (saved so we can restore it on forward). pub fn go_back(&mut self, current: &str) -> Option { diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs new file mode 100644 index 0000000..41c6bc9 --- /dev/null +++ b/src/tui/history_search.rs @@ -0,0 +1,144 @@ +/// Incremental reverse-history-search state (Ctrl+R). +/// +/// The search is case-insensitive substring match, walking backwards +/// through the history list (most-recent-first). Successive Ctrl+R presses +/// cycle to older matches. +pub struct HistorySearch { + /// The characters the user has typed so far. + query: String, + /// Index into `History::entries` of the current match (None = no match). + matched_idx: Option, + /// Text that was in the textarea when search began; restored on Escape. + saved_content: String, +} + +impl HistorySearch { + pub fn new(saved_content: String) -> Self { + Self { + query: String::new(), + matched_idx: None, + saved_content, + } + } + + pub fn query(&self) -> &str { + &self.query + } + + /// The matched history entry, if any. + pub fn matched<'a>(&self, entries: &'a [String]) -> Option<&'a str> { + self.matched_idx.map(|i| entries[i].as_str()) + } + + /// The textarea content to restore when the user presses Escape. + pub fn saved_content(&self) -> &str { + &self.saved_content + } + + // ── Search helpers ─────────────────────────────────────────────────────── + + /// Search backwards through `entries[..before]` for an entry containing + /// `query_lower`. Returns the index of the first (most-recent) hit. + fn search_before(query_lower: &str, entries: &[String], before: usize) -> Option { + let end = before.min(entries.len()); + if query_lower.is_empty() { + // No query → match the most-recent entry unconditionally. + return if end > 0 { Some(end - 1) } else { None }; + } + (0..end).rev().find(|&i| entries[i].to_lowercase().contains(query_lower)) + } + + // ── Public mutation ────────────────────────────────────────────────────── + + /// Append `c` to the query and re-search from the most-recent entry. + pub fn push_char(&mut self, c: char, entries: &[String]) { + self.query.push(c); + let q = self.query.to_lowercase(); + // Re-search from the current match position (or end of list) so that + // adding a character narrows the match rather than jumping further back. + let before = self.matched_idx.map(|i| i + 1).unwrap_or(entries.len()); + self.matched_idx = Self::search_before(&q, entries, before); + } + + /// Remove the last character from the query and re-search from the end. + pub fn pop_char(&mut self, entries: &[String]) { + self.query.pop(); + let q = self.query.to_lowercase(); + // After narrowing the query the most-recent match might have moved + // forward, so restart from the end of the list. + self.matched_idx = Self::search_before(&q, entries, entries.len()); + } + + /// Cycle to the next older match (invoked by a second Ctrl+R). + pub fn search_older(&mut self, entries: &[String]) { + let q = self.query.to_lowercase(); + // Search strictly before the current match. + let before = self.matched_idx.unwrap_or(entries.len()); + self.matched_idx = Self::search_before(&q, entries, before); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entries() -> Vec { + vec![ + "SELECT 1;".to_string(), + "SELECT * FROM orders;".to_string(), + "SELECT * FROM users;".to_string(), + "INSERT INTO orders VALUES (1);".to_string(), + ] + } + + #[test] + fn test_empty_query_matches_most_recent() { + let mut s = HistorySearch::new(String::new()); + let e = entries(); + // Without typing anything, matched_idx should be None (not started yet). + assert!(s.matched(&e).is_none()); + // After pushing then deleting (empty query), most-recent entry returned. + s.push_char('x', &e); + s.pop_char(&e); + assert_eq!(s.matched(&e), Some("INSERT INTO orders VALUES (1);")); + } + + #[test] + fn test_push_narrows_match() { + let mut s = HistorySearch::new(String::new()); + let e = entries(); + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + // Most recent "sel" match should be index 3 (INSERT has no sel), + // index 2 "SELECT * FROM users;" → yes + assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); + } + + #[test] + fn test_ctrl_r_cycles_older() { + let mut s = HistorySearch::new(String::new()); + let e = entries(); + // "sel" narrows to SELECT entries only (INSERT doesn't contain "sel") + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + // First match: most-recent SELECT entry + assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); + s.search_older(&e); + // Next older: "SELECT * FROM orders;" (index 1) + assert_eq!(s.matched(&e), Some("SELECT * FROM orders;")); + s.search_older(&e); + // Next older: "SELECT 1;" (index 0) + assert_eq!(s.matched(&e), Some("SELECT 1;")); + s.search_older(&e); + // No more matches — stays at None + assert!(s.matched(&e).is_none()); + } + + #[test] + fn test_saved_content() { + let s = HistorySearch::new("my draft query".to_string()); + assert_eq!(s.saved_content(), "my draft query"); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4dd2353..721d491 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,4 +1,5 @@ pub mod history; +pub mod history_search; pub mod layout; pub mod output_pane; @@ -37,6 +38,7 @@ use crate::viewer::open_csvlens_viewer; use crate::CLI_VERSION; use history::History; +use history_search::HistorySearch; use layout::compute_layout; use output_pane::OutputPane; @@ -58,6 +60,9 @@ pub struct TuiApp { spinner_tick: u64, running_hint: String, // e.g. "Showing first N rows — collecting remainder..." + /// Active Ctrl+R reverse-search session; `None` when not searching. + history_search: Option, + // After leaving alt-screen for csvlens we need a full redraw needs_clear: bool, should_quit: bool, @@ -92,6 +97,7 @@ impl TuiApp { query_start: None, spinner_tick: 0, running_hint: String::new(), + history_search: None, needs_clear: false, should_quit: false, has_error: false, @@ -238,6 +244,11 @@ impl TuiApp { return false; } + // ── Ctrl+R history search mode ──────────────────────────────────────── + if self.history_search.is_some() { + return self.handle_history_search_key(key).await; + } + match (key.code, key.modifiers) { // ── Exit ────────────────────────────────────────────────────── (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => { @@ -245,6 +256,12 @@ impl TuiApp { return true; } + // ── Ctrl+R: start reverse history search ───────────────────── + (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { + let saved = self.textarea.lines().join("\n"); + self.history_search = Some(HistorySearch::new(saved)); + } + // ── Cancel / clear ──────────────────────────────────────────── (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { self.reset_textarea(); @@ -351,6 +368,83 @@ impl TuiApp { false } + // ── Ctrl+R history search ──────────────────────────────────────────────── + + /// Key handler active while `history_search` is `Some`. + /// Returns `true` if the app should quit (never, but matches signature). + async fn handle_history_search_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + let entries = self.history.entries().to_vec(); + + match (key.code, key.modifiers) { + // Ctrl+R: cycle to next older match + (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { + if let Some(hs) = &mut self.history_search { + hs.search_older(&entries); + } + } + + // Escape or Ctrl+G: cancel, restore saved content + (KeyCode::Esc, _) + | (KeyCode::Char('g'), crossterm::event::KeyModifiers::CONTROL) => { + let saved = self + .history_search + .take() + .map(|hs| hs.saved_content().to_string()) + .unwrap_or_default(); + self.set_textarea_content(&saved); + } + + // Ctrl+C: cancel and clear + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + self.history_search = None; + self.reset_textarea(); + } + + // Enter: accept the current match + (KeyCode::Enter, _) => { + let matched = self + .history_search + .as_ref() + .and_then(|hs| hs.matched(&entries)) + .map(|s| s.to_string()); + self.history_search = None; + let content = matched.unwrap_or_default(); + self.set_textarea_content(&content); + } + + // Backspace: remove one character from the query + (KeyCode::Backspace, _) => { + if let Some(hs) = &mut self.history_search { + hs.pop_char(&entries); + } + } + + // Printable character: append to search query + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + if let Some(hs) = &mut self.history_search { + hs.push_char(c, &entries); + } + } + + // Any other key: accept match, exit search, re-process key normally + _ => { + let matched = self + .history_search + .as_ref() + .and_then(|hs| hs.matched(&entries)) + .map(|s| s.to_string()); + self.history_search = None; + if let Some(content) = matched { + self.set_textarea_content(&content); + } + // Re-dispatch to normal handler + return Box::pin(self.handle_key(key)).await; + } + } + + false + } + // ── Backslash commands ─────────────────────────────────────────────────── async fn handle_backslash_command(&mut self, cmd: &str) { @@ -511,6 +605,8 @@ impl TuiApp { if self.is_running { self.render_running_pane(f, layout.input); + } else if let Some(hs) = &self.history_search { + self.render_history_search_pane(f, layout.input, hs); } else { // Input area: outer block provides top + bottom separator lines let outer_block = Block::default() @@ -562,6 +658,31 @@ impl TuiApp { f.render_widget(Paragraph::new(lines), inner); } + fn render_history_search_pane(&self, f: &mut ratatui::Frame, area: Rect, hs: &HistorySearch) { + let outer_block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_style(Style::default().fg(Color::Cyan)); + let inner = outer_block.inner(area); + f.render_widget(outer_block, area); + + let entries = self.history.entries(); + let matched = hs.matched(entries).unwrap_or(""); + + // First line: "(reverse-i-search)`query':" + let query_line = Line::from(vec![ + Span::styled("(reverse-i-search)`", Style::default().fg(Color::DarkGray)), + Span::styled(hs.query().to_string(), Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD)), + Span::styled("':", Style::default().fg(Color::DarkGray)), + ]); + + // Second line: the matched entry (truncated to fit) + let width = inner.width as usize; + let display: String = matched.chars().take(width).collect(); + let matched_line = Line::from(Span::styled(display, Style::default().fg(Color::Yellow))); + + f.render_widget(Paragraph::new(vec![query_line, matched_line]), inner); + } + fn render_status_bar(&self, f: &mut ratatui::Frame, area: Rect) { let host = &self.context.args.host; let db = &self.context.args.database; @@ -569,8 +690,10 @@ impl TuiApp { let left = format!(" {} | {} | v{}", host, db, CLI_VERSION); let right = if self.is_running { " Ctrl+C cancel ".to_string() + } else if self.history_search.is_some() { + " Enter accept Ctrl+R older Esc cancel ".to_string() } else { - " Ctrl+D exit Ctrl+H help ".to_string() + " Ctrl+D exit Ctrl+R search Ctrl+H help ".to_string() }; // Pad between left and right diff --git a/tests/cli.rs b/tests/cli.rs index e6db6d8..443b3a6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -89,15 +89,15 @@ fn test_params_escaping() { assert!(output.status.success()); let mut lines = stdout.lines(); // First query result - assert_eq!(lines.next().unwrap(), "?column?"); + assert_eq!(lines.next().unwrap(), "param"); assert_eq!(lines.next().unwrap(), "text"); assert_eq!(lines.next().unwrap(), "a=}&"); // Second query result - assert_eq!(lines.next().unwrap(), "?column?"); + assert_eq!(lines.next().unwrap(), "param"); assert_eq!(lines.next().unwrap(), "text"); assert_eq!(lines.next().unwrap(), "a=}&"); // Third query result - assert_eq!(lines.next().unwrap(), "?column?"); + assert_eq!(lines.next().unwrap(), "param"); assert_eq!(lines.next().unwrap(), "text"); assert_eq!(lines.next().unwrap(), "b=}&"); } From d90adb10f66bf85b28dbefb2b97a5e05b99472eb Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 16:54:35 +0100 Subject: [PATCH 029/147] Add streaming progress bar during query execution (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows live row count in the running pane while a query streams results. The query thread sends TuiMsg::Progress(n) after each data batch; the TUI updates progress_rows and renders "⠸ 1.4s 12,345 rows received". Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 6 ++++++ src/tui/mod.rs | 29 ++++++++++++++++++++++++++++- src/tui_msg.rs | 2 ++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/query.rs b/src/query.rs index 89b8230..ced3341 100644 --- a/src/query.rs +++ b/src/query.rs @@ -472,6 +472,12 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } all_rows.push(row); } + // Send live row count to the TUI running pane. + if context.is_tui() { + if let Some(tx) = &context.tui_output_tx { + let _ = tx.send(crate::tui_msg::TuiMsg::Progress(all_rows.len() as u64)); + } + } } JsonLineMessage::FinishSuccessfully { statistics: s } => { statistics = s; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 721d491..fa534ad 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -44,6 +44,18 @@ use output_pane::OutputPane; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +fn format_with_commas(n: u64) -> String { + let s = n.to_string(); + let mut result = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} + pub struct TuiApp { context: Context, textarea: TextArea<'static>, @@ -59,6 +71,7 @@ pub struct TuiApp { query_start: Option, spinner_tick: u64, running_hint: String, // e.g. "Showing first N rows — collecting remainder..." + progress_rows: u64, // rows received so far (streamed from query task) /// Active Ctrl+R reverse-search session; `None` when not searching. history_search: Option, @@ -97,6 +110,7 @@ impl TuiApp { query_start: None, spinner_tick: 0, running_hint: String::new(), + progress_rows: 0, history_search: None, needs_clear: false, should_quit: false, @@ -200,6 +214,9 @@ impl TuiApp { loop { match rx.try_recv() { + Ok(TuiMsg::Progress(n)) => { + self.progress_rows = n; + } Ok(TuiMsg::StyledLines(lines)) => { self.output.push_tui_lines(lines); } @@ -542,6 +559,7 @@ impl TuiApp { self.cancel_token = Some(cancel_token.clone()); self.is_running = true; self.running_hint.clear(); + self.progress_rows = 0; self.query_start = Some(Instant::now()); // Build a context clone with the TUI output channel attached @@ -640,7 +658,16 @@ impl TuiApp { let spinner = SPINNER_FRAMES[(self.spinner_tick / 2) as usize % SPINNER_FRAMES.len()]; let elapsed = if let Some(start) = self.query_start { - format!("{} {:.1}s", spinner, start.elapsed().as_secs_f64()) + if self.progress_rows > 0 { + format!( + "{} {:.1}s {:>} rows received", + spinner, + start.elapsed().as_secs_f64(), + format_with_commas(self.progress_rows), + ) + } else { + format!("{} {:.1}s", spinner, start.elapsed().as_secs_f64()) + } } else { spinner.to_string() }; diff --git a/src/tui_msg.rs b/src/tui_msg.rs index fcd9537..6ec0328 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -4,6 +4,8 @@ pub enum TuiMsg { Line(String), /// Pre-styled lines produced by the custom TUI table renderer. StyledLines(Vec), + /// Streaming progress update: total data rows received so far. + Progress(u64), } /// A single rendered line made up of zero or more styled spans. From 9ed0179e8f5b067c7ab0784a958d4b679e56ec2f Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:05:26 +0100 Subject: [PATCH 030/147] Add per-token syntax highlighting to TUI input textarea (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keywords (cyan/bold), strings (yellow), numbers (magenta), comments (gray) and functions (blue) are highlighted as the user types. Implemented by computing byte-range spans from the existing regex patterns in highlight.rs, then post-processing the ratatui buffer after tui-textarea renders — this works around tui-textarea 0.7 having no built-in multi-color highlight API. Also adds the tree-sitter + devgen-tree-sitter-sql crates and a new sql_parser.rs module (create_parser / sql_language) that Phase 6 will use for AST-based completion context detection. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 22 ++++ Cargo.toml | 2 + src/highlight.rs | 301 ++++++++++++++++++++++++++++++++-------------- src/main.rs | 1 + src/sql_parser.rs | 17 +++ src/tui/mod.rs | 70 +++++++++++ 6 files changed, 325 insertions(+), 88 deletions(-) create mode 100644 src/sql_parser.rs diff --git a/Cargo.lock b/Cargo.lock index c56cd8a..8bf9912 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "devgen-tree-sitter-sql" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19761d763d00dc4dba06f50488b2a72979a36840760faea031480aec88dba559" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "digest" version = "0.10.7" @@ -782,6 +792,7 @@ dependencies = [ "comfy-table", "crossterm 0.28.1", "csvlens", + "devgen-tree-sitter-sql", "dirs", "gumdrop", "once_cell", @@ -798,6 +809,7 @@ dependencies = [ "tokio", "tokio-util", "toml", + "tree-sitter", "tui-textarea", "urlencoding", ] @@ -2774,6 +2786,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705bf7c0958d0171dd7d3a6542f2f4f21d87ed5f1dc8db52919d3a6bed9a359a" +dependencies = [ + "cc", + "regex", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/Cargo.toml b/Cargo.toml index acd6a5b..b653c3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,5 @@ pest_derive = "2.7" terminal_size = "0.3" comfy-table = "6.2" csvlens = "0.14" +devgen-tree-sitter-sql = "0.21.0" +tree-sitter = "0.21.0" diff --git a/src/highlight.rs b/src/highlight.rs index 109632b..7cf8777 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1,7 +1,9 @@ use once_cell::sync::Lazy; +use ratatui::style::{Color, Modifier, Style}; use regex::Regex; +use std::ops::Range; -/// Color scheme using ANSI escape codes +/// Color scheme using ANSI escape codes (for headless/non-TUI output) pub struct ColorScheme { keyword: &'static str, function: &'static str, @@ -26,6 +28,25 @@ impl ColorScheme { } } +/// Ratatui styles for TUI syntax highlighting +fn keyword_style() -> Style { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) +} +fn function_style() -> Style { + Style::default().fg(Color::Blue) +} +fn string_style() -> Style { + Style::default().fg(Color::Yellow) +} +fn number_style() -> Style { + Style::default().fg(Color::Magenta) +} +fn comment_style() -> Style { + Style::default().fg(Color::DarkGray) +} + // SQL Keywords pattern static KEYWORD_PATTERN: Lazy = Lazy::new(|| { Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|OUTER|FULL|ON|AND|OR|NOT|IN|IS|NULL|LIKE|BETWEEN|GROUP|BY|HAVING|ORDER|ASC|DESC|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|ALTER|DROP|TABLE|VIEW|INDEX|AS|DISTINCT|ALL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|RETURNING|CAST|EXTRACT|INTERVAL|EXISTS|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|DEFAULT|UNIQUE|CHECK|CROSS)\b").unwrap() @@ -37,20 +58,133 @@ static FUNCTION_PATTERN: Lazy = Lazy::new(|| { }); // String pattern (single quotes with escape sequences) -static STRING_PATTERN: Lazy = Lazy::new(|| Regex::new(r"'(?:[^']|''|\\')*'").unwrap()); +static STRING_PATTERN: Lazy = Lazy::new(|| Regex::new(r"'(?:[^'\\]|''|\\.)*'").unwrap()); // Number pattern static NUMBER_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\b\d+\.?\d*([eE][+-]?\d+)?\b").unwrap()); -// Comment patterns +// Comment patterns (applied to full multi-line text) static LINE_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"--[^\n]*").unwrap()); static BLOCK_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"/\*[\s\S]*?\*/").unwrap()); // Operator pattern static OPERATOR_PATTERN: Lazy = Lazy::new(|| Regex::new(r"[=<>!]+|[+\-*/%]|\|\||::").unwrap()); -/// SQL syntax highlighter using regex patterns. -/// Produces ANSI-escaped strings for headless (non-TUI) display. +/// Holds a non-overlapping list of (start, end, priority) spans. +/// Higher priority wins when two spans overlap. +struct SpanList { + entries: Vec<(usize, usize, u8)>, // (start, end, priority) +} + +impl SpanList { + fn new() -> Self { + Self { entries: Vec::new() } + } + + fn add(&mut self, start: usize, end: usize, priority: u8) { + self.entries.push((start, end, priority)); + } + + /// Returns the highest-priority span that contains `pos`, if any. + fn style_at(&self, pos: usize) -> Option { + self.entries + .iter() + .filter(|(s, e, _)| pos >= *s && pos < *e) + .max_by_key(|(_, _, p)| *p) + .map(|(_, _, p)| *p) + } + + /// Collect non-overlapping byte ranges → (start, end, priority), sorted by start. + /// When multiple spans overlap the same byte, the highest priority wins. + fn into_sorted_ranges(mut self) -> Vec<(usize, usize, u8)> { + if self.entries.is_empty() { + return vec![]; + } + // Determine overall extent + let max_end = self.entries.iter().map(|(_, e, _)| *e).max().unwrap_or(0); + let mut result: Vec<(usize, usize, u8)> = Vec::new(); + let mut i = 0usize; + while i < max_end { + let p = self.style_at(i); + match p { + None => i += 1, + Some(prio) => { + // Find how far this priority spans continuously + let start = i; + while i < max_end && self.style_at(i) == Some(prio) { + i += 1; + } + result.push((start, i, prio)); + } + } + } + result + } +} + +// Map priority numbers to tokens +const PRIO_COMMENT: u8 = 5; +const PRIO_STRING: u8 = 4; +const PRIO_FUNCTION: u8 = 3; +const PRIO_KEYWORD: u8 = 2; +const PRIO_NUMBER: u8 = 1; + +fn prio_to_ratatui_style(prio: u8) -> Style { + match prio { + PRIO_COMMENT => comment_style(), + PRIO_STRING => string_style(), + PRIO_FUNCTION => function_style(), + PRIO_KEYWORD => keyword_style(), + PRIO_NUMBER => number_style(), + _ => Style::default(), + } +} + +fn prio_to_ansi_color<'a>(prio: u8, scheme: &'a ColorScheme) -> &'a str { + match prio { + PRIO_COMMENT => scheme.comment, + PRIO_STRING => scheme.string, + PRIO_FUNCTION => scheme.function, + PRIO_KEYWORD => scheme.keyword, + PRIO_NUMBER => scheme.number, + _ => scheme.reset, + } +} + +/// Compute raw highlight ranges for `text` (multi-line OK). +/// Returns a sorted list of non-overlapping `(byte_range, priority)` pairs. +fn compute_ranges(text: &str) -> Vec<(Range, u8)> { + let mut spans = SpanList::new(); + + for mat in LINE_COMMENT_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_COMMENT); + } + for mat in BLOCK_COMMENT_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_COMMENT); + } + for mat in STRING_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_STRING); + } + for mat in FUNCTION_PATTERN.find_iter(text) { + // Don't include the opening paren in the function name span + spans.add(mat.start(), mat.end() - 1, PRIO_FUNCTION); + } + for mat in KEYWORD_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_KEYWORD); + } + for mat in NUMBER_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_NUMBER); + } + + spans + .into_sorted_ranges() + .into_iter() + .map(|(s, e, p)| (s..e, p)) + .collect() +} + +/// SQL syntax highlighter. +/// Produces ANSI-escaped strings for headless display and ratatui `Style` spans for TUI. pub struct SqlHighlighter { color_scheme: ColorScheme, enabled: bool, @@ -64,101 +198,43 @@ impl SqlHighlighter { }) } - /// Highlight SQL text by applying ANSI color codes. + /// Highlight SQL text by applying ANSI color codes (headless / non-TUI mode). pub fn highlight_sql(&self, line: &str) -> String { if !self.enabled || line.is_empty() { return line.to_string(); } - let mut highlights: Vec<(usize, usize, &str)> = Vec::new(); - - // Comments first (highest priority) - for mat in LINE_COMMENT_PATTERN.find_iter(line) { - highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); - } - for mat in BLOCK_COMMENT_PATTERN.find_iter(line) { - highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); - } - - // Strings (high priority) - for mat in STRING_PATTERN.find_iter(line) { - highlights.push((mat.start(), mat.end(), self.color_scheme.string)); - } - - // Collect other matches - let mut keyword_matches = Vec::new(); - for mat in KEYWORD_PATTERN.find_iter(line) { - keyword_matches.push((mat.start(), mat.end())); - } - - let mut function_matches = Vec::new(); - for mat in FUNCTION_PATTERN.find_iter(line) { - function_matches.push((mat.start(), mat.end() - 1)); - } - - let mut number_matches = Vec::new(); - for mat in NUMBER_PATTERN.find_iter(line) { - number_matches.push((mat.start(), mat.end())); - } - - let mut operator_matches = Vec::new(); - for mat in OPERATOR_PATTERN.find_iter(line) { - operator_matches.push((mat.start(), mat.end())); - } - - // Add non-overlapping matches - let overlaps = |start: usize, end: usize, existing: &[(usize, usize, &str)]| { - existing.iter().any(|(s, e, _)| { - (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e) - }) - }; - - for (start, end) in keyword_matches { - if !overlaps(start, end, &highlights) { - highlights.push((start, end, self.color_scheme.keyword)); - } - } - for (start, end) in function_matches { - if !overlaps(start, end, &highlights) { - highlights.push((start, end, self.color_scheme.function)); - } - } - for (start, end) in number_matches { - if !overlaps(start, end, &highlights) { - highlights.push((start, end, self.color_scheme.number)); - } - } - for (start, end) in operator_matches { - if !overlaps(start, end, &highlights) { - highlights.push((start, end, self.color_scheme.operator)); - } - } - - highlights.sort_by_key(|h| h.0); - + let ranges = compute_ranges(line); + let scheme = &self.color_scheme; let mut result = String::with_capacity(line.len() * 2); - let mut last_pos = 0; - let reset = self.color_scheme.reset; + let mut last = 0usize; - for (start, end, color) in highlights { - if start < last_pos { - continue; - } - if start > last_pos { - result.push_str(&line[last_pos..start]); + for (range, prio) in &ranges { + if range.start > last { + result.push_str(&line[last..range.start]); } - result.push_str(color); - result.push_str(&line[start..end]); - result.push_str(reset); - last_pos = end; + result.push_str(prio_to_ansi_color(*prio, scheme)); + result.push_str(&line[range.start..range.end]); + result.push_str(scheme.reset); + last = range.end; } - - if last_pos < line.len() { - result.push_str(&line[last_pos..]); + if last < line.len() { + result.push_str(&line[last..]); } - result } + + /// Return byte-range → ratatui `Style` spans for the given (possibly multi-line) SQL text. + /// Spans are non-overlapping and sorted by start offset. + pub fn highlight_to_spans(&self, text: &str) -> Vec<(Range, Style)> { + if !self.enabled || text.is_empty() { + return vec![]; + } + compute_ranges(text) + .into_iter() + .map(|(range, prio)| (range, prio_to_ratatui_style(prio))) + .collect() + } } #[cfg(test)] @@ -223,6 +299,7 @@ mod tests { fn test_keywords_in_strings_not_highlighted() { let highlighter = SqlHighlighter::new(true).unwrap(); let result = highlighter.highlight_sql("SELECT 'SELECT FROM WHERE'"); + // Only the outer SELECT keyword should be highlighted; the ones inside the string should not let keyword_count = result.matches("\x1b[94m").count(); assert_eq!(keyword_count, 1); } @@ -263,4 +340,52 @@ mod tests { let result = highlighter.highlight_sql("SELECT /* comment */ 1"); assert!(result.contains("\x1b[90m")); } + + #[test] + fn test_highlight_to_spans_disabled() { + let h = SqlHighlighter::new(false).unwrap(); + let spans = h.highlight_to_spans("SELECT 1"); + assert!(spans.is_empty()); + } + + #[test] + fn test_highlight_to_spans_keywords() { + let h = SqlHighlighter::new(true).unwrap(); + let spans = h.highlight_to_spans("SELECT 1 FROM t"); + // Should have spans for SELECT and FROM + assert!(!spans.is_empty()); + // SELECT is at offset 0..6 + let select_span = spans.iter().find(|(r, _)| r.start == 0 && r.end == 6); + assert!(select_span.is_some()); + } + + #[test] + fn test_highlight_to_spans_multiline() { + let h = SqlHighlighter::new(true).unwrap(); + let sql = "SELECT id\nFROM orders"; + let spans = h.highlight_to_spans(sql); + assert!(!spans.is_empty()); + // Should have spans for both SELECT and FROM + let has_select = spans.iter().any(|(r, _)| r.start == 0 && r.end == 6); + let from_offset = sql.find("FROM").unwrap(); + let has_from = spans.iter().any(|(r, _)| r.start == from_offset); + assert!(has_select); + assert!(has_from); + } + + #[test] + fn test_highlight_to_spans_no_overlap() { + let h = SqlHighlighter::new(true).unwrap(); + let sql = "SELECT id, name FROM orders WHERE id = 1 AND name = 'Alice'"; + let spans = h.highlight_to_spans(sql); + // Ensure spans are non-overlapping and sorted + for i in 1..spans.len() { + assert!( + spans[i].0.start >= spans[i - 1].0.end, + "Spans overlap: {:?} and {:?}", + spans[i - 1].0, + spans[i].0 + ); + } + } } diff --git a/src/main.rs b/src/main.rs index 0bb4532..647cd7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod context; mod highlight; mod meta_commands; mod query; +mod sql_parser; mod table_renderer; mod tui; mod tui_msg; diff --git a/src/sql_parser.rs b/src/sql_parser.rs new file mode 100644 index 0000000..1816ddd --- /dev/null +++ b/src/sql_parser.rs @@ -0,0 +1,17 @@ +/// Shared tree-sitter SQL parser infrastructure. +/// +/// Used by the completion system (Phase 6) for AST-based context detection. +/// Each component that needs parsing should call `create_parser()` to get its +/// own `Parser` instance — `tree_sitter::Parser` is not `Sync`. + +pub fn sql_language() -> tree_sitter::Language { + devgen_tree_sitter_sql::language() +} + +pub fn create_parser() -> tree_sitter::Parser { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&sql_language()) + .expect("tree-sitter SQL grammar should always load"); + parser +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index fa534ad..92d2fe9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -32,6 +32,7 @@ use crate::completion::schema_cache::SchemaCache; use crate::completion::usage_tracker::UsageTracker; use crate::completion::SqlCompleter; use crate::context::Context; +use crate::highlight::SqlHighlighter; use crate::meta_commands::handle_meta_command; use crate::query::{query, try_split_queries}; use crate::viewer::open_csvlens_viewer; @@ -63,6 +64,7 @@ pub struct TuiApp { history: History, schema_cache: Arc, completer: SqlCompleter, + highlighter: SqlHighlighter, // Query execution state query_rx: Option>, @@ -96,6 +98,9 @@ impl TuiApp { let _ = history.load(); let textarea = Self::make_textarea(); + let highlighter = SqlHighlighter::new(!context.args.no_completion).unwrap_or_else(|_| { + SqlHighlighter::new(false).unwrap() + }); Self { context, @@ -104,6 +109,7 @@ impl TuiApp { history, schema_cache, completer, + highlighter, query_rx: None, cancel_token: None, is_running: false, @@ -643,12 +649,76 @@ impl TuiApp { let prompt = Paragraph::new("❯").style(Style::default().fg(Color::Green)); f.render_widget(prompt, chunks[0]); f.render_widget(&self.textarea, chunks[1]); + + // Apply per-token syntax highlighting to the rendered textarea buffer + let textarea_area = chunks[1]; + self.apply_textarea_highlights(f.buffer_mut(), textarea_area); } // Status bar self.render_status_bar(f, layout.status); } + /// Apply syntax highlighting to the textarea by post-processing the ratatui buffer. + /// + /// After `tui-textarea` renders its content (cursor, selection, etc.) we walk + /// through the buffer cells corresponding to the textarea's screen area and + /// patch the foreground colour of "plain" text cells. We skip the cursor + /// character so the block-cursor stays visible. + fn apply_textarea_highlights( + &self, + buf: &mut ratatui::buffer::Buffer, + area: ratatui::layout::Rect, + ) { + let lines = self.textarea.lines(); + let full_text = lines.join("\n"); + let spans = self.highlighter.highlight_to_spans(&full_text); + if spans.is_empty() { + return; + } + + let (cursor_row, cursor_col) = self.textarea.cursor(); + + let mut byte_offset = 0usize; + for (line_idx, line) in lines.iter().enumerate() { + let screen_y = area.y + line_idx as u16; + if screen_y >= area.y + area.height { + break; + } + + let mut char_col = 0usize; + for (byte_in_line, _ch) in line.char_indices() { + let byte_pos = byte_offset + byte_in_line; + let screen_x = area.x + char_col as u16; + if screen_x >= area.x + area.width { + break; + } + + // Skip the cursor character — it has special styling (REVERSED) + // that we want to preserve exactly as tui-textarea rendered it. + if line_idx == cursor_row && char_col == cursor_col { + char_col += 1; + continue; + } + + // Find the highest-priority span covering this byte position. + if let Some((_, style)) = + spans.iter().find(|(r, _)| r.start <= byte_pos && byte_pos < r.end) + { + let pos = ratatui::layout::Position::new(screen_x, screen_y); + if let Some(cell) = buf.cell_mut(pos) { + // Patch only the foreground colour; preserve bg, modifiers etc. + let current = cell.style(); + cell.set_style(current.patch(*style)); + } + } + char_col += 1; + } + + byte_offset += line.len() + 1; // +1 for the '\n' joining lines + } + } + fn render_running_pane(&self, f: &mut ratatui::Frame, area: Rect) { let outer_block = Block::default() .borders(Borders::TOP | Borders::BOTTOM) From 2f03f068df6858bd36db4ba1bfdf36b73fe18862 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:10:02 +0100 Subject: [PATCH 031/147] Add floating tab-completion popup to the TUI (Phase 6) Tab key triggers completion against the schema cache; a cyan-bordered floating List widget appears just above the input pane showing up to 10 candidates with their type descriptions (table / column / function). Navigation: Tab / Down = next, Shift+Tab / Up = previous, Enter = accept, Escape = close. Single-candidate completions are accepted inline without showing the popup. Also adds CompletionItem struct to the completion module so the TUI popup can show item descriptions alongside completion values. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 39 ++++++-- src/tui/completion_popup.rs | 177 ++++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 132 +++++++++++++++++++++++++++ 3 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 src/tui/completion_popup.rs diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 8a15370..34e8803 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -11,6 +11,17 @@ use schema_cache::SchemaCache; use usage_tracker::{ItemType, UsageTracker}; use std::sync::Arc; +/// A single completion candidate with display metadata. +#[derive(Debug, Clone)] +pub struct CompletionItem { + /// The text to insert when accepted. + pub value: String, + /// Short description shown in the popup (type label or schema name). + pub description: String, + /// The logical type of the item. + pub item_type: ItemType, +} + pub struct SqlCompleter { cache: Arc, usage_tracker: Arc, @@ -42,11 +53,11 @@ impl SqlCompleter { } /// Return a list of completion candidates for the given input line and cursor position. - /// Returns `(word_start, candidates)` where `word_start` is the byte offset of the - /// start of the word being completed and `candidates` are the replacement strings. - pub fn complete_at(&self, line: &str, pos: usize) -> (usize, Vec) { + /// Returns `(word_start, items)` where `word_start` is the byte offset of the + /// start of the word being completed. + pub fn complete_at(&self, line: &str, pos: usize) -> (usize, Vec) { if !self.enabled { - return (0, Vec::new()); + return (0, Vec::::new()); } // Find the start of the word we're completing @@ -189,12 +200,24 @@ impl SqlCompleter { // Sort by score descending scored.sort_by(|a, b| b.score.cmp(&a.score)); - // Deduplicate (keep highest-scored occurrence) + // Deduplicate (keep highest-scored occurrence), then convert to CompletionItem let mut seen = std::collections::HashSet::new(); - let candidates: Vec = scored + let candidates: Vec = scored .into_iter() .filter(|s| seen.insert(s.name.clone())) - .map(|s| s.name) + .map(|s| { + let description = match s.item_type { + ItemType::Table => "table", + ItemType::Column => "column", + ItemType::Function => "function", + } + .to_string(); + CompletionItem { + value: s.name, + description, + item_type: s.item_type, + } + }) .collect(); (word_start, candidates) @@ -224,6 +247,6 @@ mod tests { let (_, candidates) = completer.complete_at("SEL", 3); // Should not return keywords (only tables and columns from schema cache) - assert!(!candidates.iter().any(|c| c == "SELECT")); + assert!(!candidates.iter().any(|c| c.value == "SELECT")); } } diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs new file mode 100644 index 0000000..5da1af3 --- /dev/null +++ b/src/tui/completion_popup.rs @@ -0,0 +1,177 @@ +/// Floating tab-completion popup rendered above the input pane. +/// +/// The popup is a bordered `List` widget positioned so that the left edge +/// aligns with the first character of the word being completed. +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem}, +}; + +use crate::completion::CompletionItem; + +/// State for an open completion popup session. +pub struct CompletionState { + /// All available completion items. + pub items: Vec, + /// Index of the currently highlighted item. + pub selected: usize, + /// Byte offset in the full textarea content where the partial word starts. + pub word_start_byte: usize, + /// Visual column (0-based) of the word start within its line. + /// Used for popup positioning. + pub word_start_col: usize, + /// Textarea cursor row (0-based) when completion was triggered. + pub cursor_row: usize, +} + +impl CompletionState { + pub fn new( + items: Vec, + word_start_byte: usize, + word_start_col: usize, + cursor_row: usize, + ) -> Self { + Self { + items, + selected: 0, + word_start_byte, + word_start_col, + cursor_row, + } + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn selected_item(&self) -> Option<&CompletionItem> { + self.items.get(self.selected) + } + + /// Advance selection to the next item (wrapping). + pub fn next(&mut self) { + if !self.items.is_empty() { + self.selected = (self.selected + 1) % self.items.len(); + } + } + + /// Move selection to the previous item (wrapping). + pub fn prev(&mut self) { + if !self.items.is_empty() { + self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1); + } + } +} + +// ── Layout helpers ─────────────────────────────────────────────────────────── + +const MAX_VISIBLE: u16 = 10; +const DESCRIPTION_WIDTH: u16 = 8; // " table " / " column " / " function" +const MIN_VALUE_WIDTH: u16 = 16; +const BORDER_OVERHEAD: u16 = 2; // left + right border + +/// Compute the area for the completion popup. +/// +/// `input_area` — the full input pane rect (borders included). +/// `textarea_x` — x coordinate of the left edge of the textarea column +/// (i.e. after the "❯ " prompt columns). +/// `total` — the full terminal area, for clamping. +pub fn popup_area( + state: &CompletionState, + input_area: Rect, + textarea_x: u16, + total: Rect, +) -> Rect { + let visible = (state.items.len() as u16).min(MAX_VISIBLE); + let popup_h = visible + BORDER_OVERHEAD; + + // Compute preferred width from longest item name + let max_name = state + .items + .iter() + .map(|i| i.value.len() as u16) + .max() + .unwrap_or(MIN_VALUE_WIDTH); + let popup_w = (max_name + DESCRIPTION_WIDTH + BORDER_OVERHEAD + 2) + .max(MIN_VALUE_WIDTH + DESCRIPTION_WIDTH + BORDER_OVERHEAD + 2) + .min(total.width.saturating_sub(2)); + + // Popup x: align with the word start column + let preferred_x = textarea_x + state.word_start_col as u16; + let x = if preferred_x + popup_w > total.width { + total.width.saturating_sub(popup_w) + } else { + preferred_x + }; + + // Popup y: just above the input pane (in the output area) + let y = input_area.y.saturating_sub(popup_h); + + Rect::new(x, y.max(0), popup_w, popup_h) +} + +// ── Rendering ──────────────────────────────────────────────────────────────── + +/// Render the completion popup to the frame. +pub fn render(state: &CompletionState, area: Rect, f: &mut ratatui::Frame) { + // Clear the background so the popup appears floating + f.render_widget(Clear, area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + let value_width = inner.width.saturating_sub(DESCRIPTION_WIDTH) as usize; + + // Build list items + let items: Vec = state + .items + .iter() + .enumerate() + .map(|(idx, item)| { + let is_selected = idx == state.selected; + + // Left: item value (truncated if needed) + let name: String = item.value.chars().take(value_width).collect(); + let pad = value_width.saturating_sub(name.len()); + let padded_name = format!("{}{}", name, " ".repeat(pad)); + + // Right: description (right-aligned in a fixed-width column) + let desc: String = item + .description + .chars() + .take(DESCRIPTION_WIDTH as usize - 1) + .collect(); + let desc_pad = (DESCRIPTION_WIDTH as usize - 1).saturating_sub(desc.len()); + let padded_desc = format!("{}{} ", " ".repeat(desc_pad), desc); + + let (name_style, desc_style) = if is_selected { + ( + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + Style::default().fg(Color::Black).bg(Color::Cyan), + ) + } else { + ( + Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), + ) + }; + + let line = Line::from(vec![ + Span::styled(padded_name, name_style), + Span::styled(padded_desc, desc_style), + ]); + ListItem::new(line) + }) + .collect(); + + let list = List::new(items); + f.render_widget(block, area); + f.render_widget(list, inner); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 92d2fe9..6239a1f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,3 +1,4 @@ +pub mod completion_popup; pub mod history; pub mod history_search; pub mod layout; @@ -38,6 +39,7 @@ use crate::query::{query, try_split_queries}; use crate::viewer::open_csvlens_viewer; use crate::CLI_VERSION; +use completion_popup::CompletionState; use history::History; use history_search::HistorySearch; use layout::compute_layout; @@ -75,6 +77,9 @@ pub struct TuiApp { running_hint: String, // e.g. "Showing first N rows — collecting remainder..." progress_rows: u64, // rows received so far (streamed from query task) + /// Active tab-completion popup session; `None` when the popup is closed. + completion_state: Option, + /// Active Ctrl+R reverse-search session; `None` when not searching. history_search: Option, @@ -117,6 +122,7 @@ impl TuiApp { spinner_tick: 0, running_hint: String::new(), progress_rows: 0, + completion_state: None, history_search: None, needs_clear: false, should_quit: false, @@ -272,6 +278,11 @@ impl TuiApp { return self.handle_history_search_key(key).await; } + // ── Completion popup navigation ─────────────────────────────────────── + if self.completion_state.is_some() { + return self.handle_completion_key(key).await; + } + match (key.code, key.modifiers) { // ── Exit ────────────────────────────────────────────────────── (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => { @@ -287,6 +298,7 @@ impl TuiApp { // ── Cancel / clear ──────────────────────────────────────────── (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + self.completion_state = None; self.reset_textarea(); self.output.push_line("^C"); self.history.reset_navigation(); @@ -297,6 +309,18 @@ impl TuiApp { self.open_viewer(); } + // ── Tab: trigger / navigate completion ─────────────────────── + (KeyCode::Tab, _) => { + self.trigger_or_advance_completion(); + } + + // ── Shift+Tab: navigate completion backwards ────────────────── + (KeyCode::BackTab, _) => { + if let Some(cs) = &mut self.completion_state { + cs.prev(); + } + } + // ── Shift+Enter: always insert newline ─────────────────────── (KeyCode::Enter, m) if m.contains(KeyModifiers::SHIFT) => { self.textarea.insert_newline(); @@ -468,6 +492,101 @@ impl TuiApp { false } + // ── Tab completion ─────────────────────────────────────────────────────── + + /// Called on Tab when no popup is open: computes candidates and opens the popup. + /// If there is exactly one candidate, accepts it immediately. + /// If the popup is already open, advances to the next item. + fn trigger_or_advance_completion(&mut self) { + if let Some(cs) = &mut self.completion_state { + cs.next(); + return; + } + + // Compute current cursor position in terms of the full content + let (cursor_row, cursor_col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + // byte offset of cursor in the full joined text + let byte_offset: usize = lines[..cursor_row].iter().map(|l| l.len() + 1).sum::() + + cursor_col; + // current line up to cursor for completion + let line_to_cursor = &lines[cursor_row][..cursor_col]; + + let (word_start_byte_in_line, items) = + self.completer.complete_at(line_to_cursor, cursor_col); + + if items.is_empty() { + return; + } + + // word_start as byte offset in the FULL text (for deletion later) + let word_start_byte = + byte_offset - (cursor_col - word_start_byte_in_line); + + // For single-item completions, accept immediately + if items.len() == 1 { + let value = items.into_iter().next().unwrap().value; + let partial_len = cursor_col - word_start_byte_in_line; + self.textarea.delete_str(partial_len); + self.textarea.insert_str(&value); + return; + } + + let cs = CompletionState::new(items, word_start_byte, word_start_byte_in_line, cursor_row); + self.completion_state = Some(cs); + } + + /// Accept the currently selected completion item, replace the partial word, close popup. + fn accept_completion(&mut self) { + let cs = match self.completion_state.take() { + Some(c) => c, + None => return, + }; + + let selected = match cs.selected_item() { + Some(item) => item.value.clone(), + None => return, + }; + + let (_, cursor_col) = self.textarea.cursor(); + let partial_len = cursor_col - cs.word_start_col; + self.textarea.delete_str(partial_len); + self.textarea.insert_str(&selected); + } + + /// Key handler active while the completion popup is open. + /// Returns `true` only if the app should quit (never, but matches signature). + async fn handle_completion_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + match (key.code, key.modifiers) { + // Tab / Down: next item + (KeyCode::Tab, _) | (KeyCode::Down, _) => { + if let Some(cs) = &mut self.completion_state { + cs.next(); + } + } + // Shift+Tab / Up: previous item + (KeyCode::BackTab, _) | (KeyCode::Up, _) => { + if let Some(cs) = &mut self.completion_state { + cs.prev(); + } + } + // Enter: accept selection (do NOT submit query) + (KeyCode::Enter, _) => { + self.accept_completion(); + } + // Escape: close without accepting + (KeyCode::Esc, _) => { + self.completion_state = None; + } + // Any other key: close popup and re-dispatch normally + _ => { + self.completion_state = None; + return Box::pin(self.handle_key(key)).await; + } + } + false + } + // ── Backslash commands ─────────────────────────────────────────────────── async fn handle_backslash_command(&mut self, cmd: &str) { @@ -653,6 +772,19 @@ impl TuiApp { // Apply per-token syntax highlighting to the rendered textarea buffer let textarea_area = chunks[1]; self.apply_textarea_highlights(f.buffer_mut(), textarea_area); + + // Render completion popup if open + if let Some(cs) = &self.completion_state { + if !cs.is_empty() { + let popup_rect = completion_popup::popup_area( + cs, + layout.input, + chunks[1].x, + area, + ); + completion_popup::render(cs, popup_rect, f); + } + } } // Status bar From b65412721b9e2312fdb55e5213aaab25d56bc7a2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:12:08 +0100 Subject: [PATCH 032/147] Add \benchmark [N] command for timing SQL queries (Phase 8) \benchmark [N] runs the query N+1 times (1 warmup + N timed), discards output, and reports min/avg/p90/max timing in the output pane. N defaults to 3 when omitted. Examples: \benchmark SELECT 1; -- 1 warmup + 3 timed runs \benchmark 10 SELECT count(*) FROM t; -- 1 warmup + 10 timed runs Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 172 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6239a1f..c7bad9a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -47,6 +47,28 @@ use output_pane::OutputPane; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +/// Parse `\benchmark [N] ` arguments. +/// Returns `(n_runs, query_text)` where `n_runs` defaults to 3. +fn parse_benchmark_args(cmd: &str) -> (usize, String) { + let rest = cmd + .strip_prefix("\\benchmark") + .unwrap_or(cmd) + .trim() + .to_string(); + + // Try to parse an optional numeric run count at the start + let mut chars = rest.splitn(2, |c: char| c.is_whitespace()); + if let Some(first) = chars.next() { + if let Ok(n) = first.parse::() { + let query = chars.next().unwrap_or("").trim().to_string(); + return (n.max(1), query); + } + } + + // No number — entire rest is the query, default 3 runs + (3, rest) +} + fn format_with_commas(n: u64) -> String { let s = n.to_string(); let mut result = String::with_capacity(s.len() + s.len() / 3); @@ -590,6 +612,14 @@ impl TuiApp { // ── Backslash commands ─────────────────────────────────────────────────── async fn handle_backslash_command(&mut self, cmd: &str) { + // Dispatch \benchmark separately since it needs async query execution + if cmd.starts_with("\\benchmark") { + self.history.add(cmd.to_string()); + let (n_runs, query_text) = parse_benchmark_args(cmd); + self.do_benchmark(n_runs, query_text).await; + return; + } + match cmd { "\\view" => self.open_viewer(), "\\refresh" | "\\refresh_cache" => self.do_refresh(), @@ -607,6 +637,107 @@ impl TuiApp { } } + // ── Benchmark ──────────────────────────────────────────────────────────── + + /// Run `query_text` N+1 times (1 warmup), collect per-run elapsed times, + /// and display min/avg/p90/max statistics in the output pane. + async fn do_benchmark(&mut self, n_runs: usize, query_text: String) { + if query_text.trim().is_empty() { + self.output.push_line( + "Usage: \\benchmark [N] (default N=3, first run is warmup)", + ); + return; + } + + let total = n_runs + 1; // 1 warmup + N timed + self.output.push_line(format!( + "Benchmarking ({} warmup + {} timed run{}):", + 1, + n_runs, + if n_runs == 1 { "" } else { "s" } + )); + self.output.push_prompt(format!("❯ {}", query_text.trim())); + + let mut times_ms: Vec = Vec::with_capacity(n_runs); + + for i in 0..total { + let queries = match crate::query::try_split_queries(query_text.trim()) { + Some(q) => q, + None => { + self.output.push_line("Error: could not parse query"); + return; + } + }; + + // Silence all output during benchmark runs + let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut ctx = self.context.clone(); + ctx.tui_output_tx = Some(tx); + + let start = Instant::now(); + let handle = tokio::spawn(async move { + for q in queries { + if crate::query::query(&mut ctx, q).await.is_err() { + return false; + } + } + true + }); + + // Drain messages (discard) while the query runs + loop { + tokio::select! { + msg = rx.recv() => { + if msg.is_none() { break; } + } + _ = tokio::time::sleep(Duration::from_millis(10)) => { + // Continue draining + } + } + // Stop when the handle has completed + if handle.is_finished() { + // Drain remaining + while rx.try_recv().is_ok() {} + break; + } + } + + let ok = handle.await.unwrap_or(false); + let elapsed = start.elapsed().as_secs_f64() * 1000.0; + + if !ok { + self.output.push_line("Error: query failed during benchmark"); + return; + } + + if i == 0 { + self.output.push_line(format!(" warmup: {:.1}ms", elapsed)); + } else { + self.output.push_line(format!(" run {}: {:.1}ms", i, elapsed)); + times_ms.push(elapsed); + } + } + + if times_ms.is_empty() { + return; + } + + // Compute statistics + let min = times_ms.iter().cloned().fold(f64::INFINITY, f64::min); + let max = times_ms.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let avg = times_ms.iter().sum::() / times_ms.len() as f64; + + let mut sorted = times_ms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p90_idx = (sorted.len() as f64 * 0.9).ceil() as usize; + let p90 = sorted[p90_idx.saturating_sub(1).min(sorted.len() - 1)]; + + self.output.push_line(format!( + "Results: min={:.1}ms avg={:.1}ms p90={:.1}ms max={:.1}ms", + min, avg, p90, max + )); + } + fn open_viewer(&mut self) { if self.context.last_result.is_none() { self.output @@ -935,3 +1066,44 @@ impl TuiApp { f.render_widget(status, area); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_benchmark_args_default() { + let (n, q) = parse_benchmark_args("\\benchmark SELECT 1"); + assert_eq!(n, 3); + assert_eq!(q, "SELECT 1"); + } + + #[test] + fn test_parse_benchmark_args_explicit_n() { + let (n, q) = parse_benchmark_args("\\benchmark 5 SELECT 1"); + assert_eq!(n, 5); + assert_eq!(q, "SELECT 1"); + } + + #[test] + fn test_parse_benchmark_args_no_query() { + let (n, q) = parse_benchmark_args("\\benchmark"); + assert_eq!(n, 3); + assert_eq!(q, ""); + } + + #[test] + fn test_parse_benchmark_args_min_n() { + // 0 should be clamped to 1 + let (n, _) = parse_benchmark_args("\\benchmark 0 SELECT 1"); + assert_eq!(n, 1); + } + + #[test] + fn test_format_with_commas() { + assert_eq!(format_with_commas(0), "0"); + assert_eq!(format_with_commas(999), "999"); + assert_eq!(format_with_commas(1000), "1,000"); + assert_eq!(format_with_commas(1234567), "1,234,567"); + } +} From d41209c9fc2f252c840d9f359ac54a29feb27cf6 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:13:33 +0100 Subject: [PATCH 033/147] Add \export command and update \help (Phase 9 partial) \export [format] writes the last query result to a file. Supported formats: csv (default), tsv, json, jsonlines. Updated \help to document all new commands and keyboard shortcuts added in Phases 5-8. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 137 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 133 insertions(+), 4 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c7bad9a..08d259b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -612,6 +612,13 @@ impl TuiApp { // ── Backslash commands ─────────────────────────────────────────────────── async fn handle_backslash_command(&mut self, cmd: &str) { + // Dispatch \export: write last result to a file + if cmd.starts_with("\\export") { + self.history.add(cmd.to_string()); + self.do_export(cmd); + return; + } + // Dispatch \benchmark separately since it needs async query execution if cmd.starts_with("\\benchmark") { self.history.add(cmd.to_string()); @@ -637,6 +644,123 @@ impl TuiApp { } } + // ── Export ─────────────────────────────────────────────────────────────── + + /// Handle `\export [format]` — write the last query result to a file. + /// Supported formats: csv (default), tsv, json, jsonlines. + fn do_export(&mut self, cmd: &str) { + // Parse: \export [format] + let rest = cmd.strip_prefix("\\export").unwrap_or("").trim(); + if rest.is_empty() { + self.output.push_line("Usage: \\export [csv|tsv|json|jsonlines]"); + return; + } + + let mut parts = rest.splitn(2, char::is_whitespace); + let path_str = parts.next().unwrap_or("").trim().to_string(); + let format = parts + .next() + .map(|s| s.trim().to_lowercase()) + .unwrap_or_else(|| "csv".to_string()); + + let result = match &self.context.last_result { + Some(r) => r.clone(), + None => { + self.output + .push_line("No query results to export. Run a query first."); + return; + } + }; + + use crate::table_renderer::write_result_as_csv; + use std::fs::File; + use std::io::{BufWriter, Write}; + + let file = match File::create(&path_str) { + Ok(f) => f, + Err(e) => { + self.output + .push_line(format!("Error: could not create file '{}': {}", path_str, e)); + return; + } + }; + let mut writer = BufWriter::new(file); + + // Helper: format a JSON value as a plain string for TSV output + fn val_to_str(v: &serde_json::Value) -> String { + match v { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } + } + + let write_result = match format.as_str() { + "tsv" => { + // Tab-separated with header + let header: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + let _ = writeln!(writer, "{}", header.join("\t")); + for row in &result.rows { + let values: Vec = row.iter().map(val_to_str).collect(); + let _ = writeln!(writer, "{}", values.join("\t")); + } + writer.flush() + } + "json" => { + // JSON array of objects + let rows_json: Vec = result + .rows + .iter() + .map(|row| { + let obj: serde_json::Map = result + .columns + .iter() + .zip(row.iter()) + .map(|(col, val)| (col.name.clone(), val.clone())) + .collect(); + serde_json::Value::Object(obj) + }) + .collect(); + let _ = writeln!(writer, "{}", serde_json::to_string_pretty(&rows_json).unwrap_or_default()); + writer.flush() + } + "jsonlines" | "jsonl" | "ndjson" => { + // One JSON object per line + for row in &result.rows { + let obj: serde_json::Map = result + .columns + .iter() + .zip(row.iter()) + .map(|(col, val)| (col.name.clone(), val.clone())) + .collect(); + let _ = writeln!(writer, "{}", serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()); + } + writer.flush() + } + _ => { + // Default: CSV + write_result_as_csv(&mut writer, &result.columns, &result.rows).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, e.to_string()) + }) + } + }; + + match write_result { + Ok(()) => { + self.output.push_line(format!( + "Exported {} rows to '{}' ({} format)", + result.rows.len(), + path_str, + format + )); + } + Err(e) => { + self.output + .push_line(format!("Error writing to '{}': {}", path_str, e)); + } + } + } + // ── Benchmark ──────────────────────────────────────────────────────────── /// Run `query_text` N+1 times (1 warmup), collect per-run elapsed times, @@ -777,9 +901,11 @@ impl TuiApp { fn show_help(&mut self) { self.output.push_ansi_text( "Special commands:\n\ - \\view - Open last query result in csvlens viewer\n\ - \\refresh - Manually refresh schema cache\n\ - \\help - Show this help message\n\ + \\view - Open last query result in csvlens viewer\n\ + \\refresh - Manually refresh schema cache\n\ + \\benchmark [N] - Run query N+1 times (1 warmup), show timing stats\n\ + \\export [fmt] - Export last result to file (csv/tsv/json/jsonlines)\n\ + \\help - Show this help message\n\ \n\ SQL-style commands:\n\ set format = ; - Change output format\n\ @@ -787,12 +913,15 @@ impl TuiApp { set completion = on/off; - Enable/disable auto-completion\n\ \n\ Keyboard shortcuts:\n\ + Tab - Open / navigate completion popup\n\ + Shift+Tab - Navigate completion popup backwards\n\ + Ctrl+R - Reverse history search\n\ Ctrl+V - Open csvlens viewer for last result\n\ Ctrl+D - Exit\n\ Ctrl+C - Cancel current input (or running query)\n\ Ctrl+Up/Down - Cycle history\n\ Page Up/Down - Scroll output pane\n\ - Enter - Submit query (when complete) or insert newline", + Shift+Enter - Insert newline without submitting", ); } From 3f4796b6086d04f52d345532c5f196e0bca77f57 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:18:23 +0100 Subject: [PATCH 034/147] Add Ctrl+Space fuzzy schema search overlay (Phase 7) - Add FuzzyCompleter using nucleo-matcher for ranked fuzzy search - Add FuzzyState and fuzzy_popup rendering in TUI - Add get_all_tables/columns/functions to SchemaCache - Ctrl+Space opens full-width overlay; type to filter, Enter to accept Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 11 +++ Cargo.toml | 1 + src/completion/fuzzy_completer.rs | 119 +++++++++++++++++++++++++ src/completion/mod.rs | 1 + src/completion/schema_cache.rs | 34 +++++++ src/tui/fuzzy_popup.rs | 142 ++++++++++++++++++++++++++++++ src/tui/mod.rs | 107 +++++++++++++++++++++- 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 src/completion/fuzzy_completer.rs create mode 100644 src/tui/fuzzy_popup.rs diff --git a/Cargo.lock b/Cargo.lock index 8bf9912..14754f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,6 +795,7 @@ dependencies = [ "devgen-tree-sitter-sql", "dirs", "gumdrop", + "nucleo-matcher", "once_cell", "openssl", "pest", @@ -1564,6 +1565,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index b653c3f..86fa4c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,4 @@ comfy-table = "6.2" csvlens = "0.14" devgen-tree-sitter-sql = "0.21.0" tree-sitter = "0.21.0" +nucleo-matcher = "0.3.1" diff --git a/src/completion/fuzzy_completer.rs b/src/completion/fuzzy_completer.rs new file mode 100644 index 0000000..6a7a21c --- /dev/null +++ b/src/completion/fuzzy_completer.rs @@ -0,0 +1,119 @@ +/// Fuzzy schema search across all tables, columns, and functions. +/// +/// Used by the Ctrl+Space overlay in the TUI. Collects all items from the +/// schema cache and applies nucleo fuzzy matching to rank them. +use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, Utf32Str, +}; +use std::sync::Arc; + +use super::{schema_cache::SchemaCache, usage_tracker::ItemType}; + +pub struct FuzzyCompleter { + cache: Arc, + matcher: Matcher, +} + +impl FuzzyCompleter { + pub fn new(cache: Arc) -> Self { + Self { + cache, + matcher: Matcher::new(Config::DEFAULT), + } + } + + /// Return all schema items that fuzzy-match `query`, ranked by score. + /// Returns up to `limit` items. + pub fn search(&mut self, query: &str, limit: usize) -> Vec { + let all_items = self.collect_all_items(); + + if query.is_empty() { + // Show first `limit` items sorted alphabetically + let mut items = all_items; + items.sort_by(|a, b| a.label.cmp(&b.label)); + items.truncate(limit); + return items; + } + + let pattern = Pattern::parse( + query, + CaseMatching::Ignore, + Normalization::Smart, + ); + + // Score every item and keep matches with score > 0 + let mut scored: Vec<(u32, FuzzyItem)> = all_items + .into_iter() + .filter_map(|item| { + let haystack = Utf32Str::Ascii(item.label.as_bytes()); + let score = pattern.score(haystack, &mut self.matcher); + score.map(|s| (s, item)) + }) + .collect(); + + // Sort by score descending + scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.truncate(limit); + scored.into_iter().map(|(_, item)| item).collect() + } + + /// Collect all schema items (tables + columns + functions) into a flat list. + fn collect_all_items(&self) -> Vec { + let mut items = Vec::new(); + + // All tables (with schema prefix for non-public) + for (schema, table) in self.cache.get_all_tables() { + let label = if schema == "public" { + table.clone() + } else { + format!("{}.{}", schema, table) + }; + items.push(FuzzyItem { + label, + description: format!("table ({})", schema), + item_type: ItemType::Table, + insert_value: if schema == "public" { + table + } else { + format!("{}.{}", schema, table) + }, + }); + } + + // All columns (unqualified; qualified duplicates would be too noisy) + for (table, column) in self.cache.get_all_columns() { + items.push(FuzzyItem { + label: format!("{}.{}", table, column), + description: format!("column of {}", table), + item_type: ItemType::Column, + insert_value: column, + }); + } + + // All functions + for function in self.cache.get_all_functions() { + items.push(FuzzyItem { + label: format!("{}()", function), + description: "function".to_string(), + item_type: ItemType::Function, + insert_value: format!("{}(", function), + }); + } + + items + } +} + +/// A single item returned by the fuzzy search. +#[derive(Clone, Debug)] +pub struct FuzzyItem { + /// Displayed text in the result list. + pub label: String, + /// Short description (type, schema). + pub description: String, + /// The logical type. + pub item_type: ItemType, + /// Text to insert into the textarea when accepted. + pub insert_value: String, +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 34e8803..345f7a8 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -1,5 +1,6 @@ pub mod context_analyzer; pub mod context_detector; +pub mod fuzzy_completer; pub mod priority_scorer; pub mod schema_cache; pub mod usage_tracker; diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index e4ed274..3529d05 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -256,6 +256,40 @@ impl SchemaCache { None } + /// Return ALL (schema, table) pairs — used by the fuzzy completer. + pub fn get_all_tables(&self) -> Vec<(String, String)> { + let tables = self.tables.read().unwrap(); + let mut result: Vec<(String, String)> = tables + .values() + .map(|t| (t.schema_name.clone(), t.table_name.clone())) + .collect(); + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Return ALL (table, column) pairs — used by the fuzzy completer. + pub fn get_all_columns(&self) -> Vec<(String, String)> { + let tables = self.tables.read().unwrap(); + let mut result: Vec<(String, String)> = tables + .values() + .flat_map(|t| { + t.columns + .iter() + .map(|c| (t.table_name.clone(), c.name.clone())) + }) + .collect(); + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Return ALL function names — used by the fuzzy completer. + pub fn get_all_functions(&self) -> Vec { + let fns = self.functions.read().unwrap(); + let mut result: Vec = fns.iter().cloned().collect(); + result.sort(); + result + } + /// Synchronous method to get completions from cache pub fn get_completions( &self, diff --git a/src/tui/fuzzy_popup.rs b/src/tui/fuzzy_popup.rs new file mode 100644 index 0000000..9fe5222 --- /dev/null +++ b/src/tui/fuzzy_popup.rs @@ -0,0 +1,142 @@ +/// Ctrl+Space fuzzy schema search overlay. +/// +/// A full-width popup that covers the input pane and lower part of the output +/// pane. The user types a search query and sees ranked schema items in real time. +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, +}; + +use crate::completion::fuzzy_completer::FuzzyItem; + +const MAX_VISIBLE: u16 = 12; +const POPUP_HEIGHT: u16 = MAX_VISIBLE + 4; // list + search line + 2 borders + +/// State for an open fuzzy-search session. +pub struct FuzzyState { + /// Current search query typed by the user. + pub query: String, + /// Ranked items from the fuzzy completer (refreshed on each query change). + pub items: Vec, + /// Currently highlighted item index. + pub selected: usize, +} + +impl FuzzyState { + pub fn new() -> Self { + Self { + query: String::new(), + items: Vec::new(), + selected: 0, + } + } + + pub fn push_char(&mut self, c: char) { + self.query.push(c); + self.selected = 0; + } + + pub fn pop_char(&mut self) { + self.query.pop(); + self.selected = 0; + } + + pub fn next(&mut self) { + if !self.items.is_empty() { + self.selected = (self.selected + 1) % self.items.len(); + } + } + + pub fn prev(&mut self) { + if !self.items.is_empty() { + self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1); + } + } + + pub fn selected_item(&self) -> Option<&FuzzyItem> { + self.items.get(self.selected) + } +} + +/// Compute the popup rect: full-width, anchored just above the input pane. +pub fn popup_area(input_area: Rect, total: Rect) -> Rect { + let h = POPUP_HEIGHT.min(input_area.y); // can't go above y=0 + let y = input_area.y.saturating_sub(h); + Rect::new(0, y, total.width, h) +} + +/// Render the fuzzy popup to the frame. +pub fn render(state: &FuzzyState, area: Rect, f: &mut ratatui::Frame) { + f.render_widget(Clear, area); + + let block = Block::default() + .title(" Fuzzy Schema Search (Enter accept · Esc close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Split inner: search line at top, results below + let chunks = Layout::vertical([ + Constraint::Length(1), // search input line + Constraint::Min(0), // results list + ]) + .split(inner); + + // ── Search line ──────────────────────────────────────────────────────── + let search_line = Line::from(vec![ + Span::styled("/ ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)), + Span::styled(state.query.clone(), Style::default().fg(Color::White)), + Span::styled("█", Style::default().fg(Color::Magenta)), // cursor indicator + ]); + f.render_widget(Paragraph::new(search_line), chunks[0]); + + // ── Results list ─────────────────────────────────────────────────────── + let desc_w = 16usize; + let label_w = chunks[1].width as usize - desc_w - 1; + + let items: Vec = state + .items + .iter() + .enumerate() + .map(|(idx, item)| { + let is_sel = idx == state.selected; + + // Truncate/pad label + let label: String = item.label.chars().take(label_w).collect(); + let pad = label_w.saturating_sub(label.len()); + let padded = format!("{}{}", label, " ".repeat(pad)); + + // Right-aligned description + let desc: String = item.description.chars().take(desc_w - 1).collect(); + let dpd = format!("{:>width$}", desc, width = desc_w - 1); + + let (l_style, d_style) = if is_sel { + ( + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + Style::default().fg(Color::Black).bg(Color::Magenta), + ) + } else { + ( + Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), + ) + }; + + let line = Line::from(vec![ + Span::styled(padded, l_style), + Span::styled(" ", if is_sel { l_style } else { Style::default() }), + Span::styled(dpd, d_style), + ]); + ListItem::new(line) + }) + .collect(); + + f.render_widget(List::new(items), chunks[1]); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 08d259b..a5581c0 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,4 +1,5 @@ pub mod completion_popup; +pub mod fuzzy_popup; pub mod history; pub mod history_search; pub mod layout; @@ -29,6 +30,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tui_textarea::{CursorMove, Input, TextArea}; +use crate::completion::fuzzy_completer::FuzzyCompleter; use crate::completion::schema_cache::SchemaCache; use crate::completion::usage_tracker::UsageTracker; use crate::completion::SqlCompleter; @@ -40,6 +42,7 @@ use crate::viewer::open_csvlens_viewer; use crate::CLI_VERSION; use completion_popup::CompletionState; +use fuzzy_popup::FuzzyState; use history::History; use history_search::HistorySearch; use layout::compute_layout; @@ -88,6 +91,7 @@ pub struct TuiApp { history: History, schema_cache: Arc, completer: SqlCompleter, + fuzzy_completer: FuzzyCompleter, highlighter: SqlHighlighter, // Query execution state @@ -102,6 +106,9 @@ pub struct TuiApp { /// Active tab-completion popup session; `None` when the popup is closed. completion_state: Option, + /// Active Ctrl+Space fuzzy search session; `None` when closed. + fuzzy_state: Option, + /// Active Ctrl+R reverse-search session; `None` when not searching. history_search: Option, @@ -128,6 +135,7 @@ impl TuiApp { let highlighter = SqlHighlighter::new(!context.args.no_completion).unwrap_or_else(|_| { SqlHighlighter::new(false).unwrap() }); + let fuzzy_completer = FuzzyCompleter::new(schema_cache.clone()); Self { context, @@ -136,6 +144,7 @@ impl TuiApp { history, schema_cache, completer, + fuzzy_completer, highlighter, query_rx: None, cancel_token: None, @@ -145,6 +154,7 @@ impl TuiApp { running_hint: String::new(), progress_rows: 0, completion_state: None, + fuzzy_state: None, history_search: None, needs_clear: false, should_quit: false, @@ -305,6 +315,11 @@ impl TuiApp { return self.handle_completion_key(key).await; } + // ── Fuzzy search overlay ────────────────────────────────────────────── + if self.fuzzy_state.is_some() { + return self.handle_fuzzy_key(key).await; + } + match (key.code, key.modifiers) { // ── Exit ────────────────────────────────────────────────────── (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => { @@ -331,6 +346,11 @@ impl TuiApp { self.open_viewer(); } + // ── Ctrl+Space: open fuzzy schema search ───────────────────── + (KeyCode::Char(' '), m) if m.contains(KeyModifiers::CONTROL) => { + self.open_fuzzy_search(); + } + // ── Tab: trigger / navigate completion ─────────────────────── (KeyCode::Tab, _) => { self.trigger_or_advance_completion(); @@ -609,6 +629,79 @@ impl TuiApp { false } + // ── Fuzzy search ───────────────────────────────────────────────────────── + + fn open_fuzzy_search(&mut self) { + let mut state = FuzzyState::new(); + state.items = self.fuzzy_completer.search("", 100); + self.fuzzy_state = Some(state); + } + + /// Key handler for the fuzzy popup. Returns true if app should quit. + async fn handle_fuzzy_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + match (key.code, key.modifiers) { + // Escape or Ctrl+Space: close + (KeyCode::Esc, _) + | (KeyCode::Char(' '), crossterm::event::KeyModifiers::CONTROL) => { + self.fuzzy_state = None; + } + + // Enter: accept selected item + (KeyCode::Enter, _) => { + if let Some(fs) = &self.fuzzy_state { + if let Some(item) = fs.selected_item() { + let value = item.insert_value.clone(); + self.fuzzy_state = None; + self.textarea.insert_str(&value); + } else { + self.fuzzy_state = None; + } + } + } + + // Down / Tab: next item + (KeyCode::Down, _) | (KeyCode::Tab, _) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.next(); + } + } + + // Up / Shift+Tab: previous item + (KeyCode::Up, _) | (KeyCode::BackTab, _) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.prev(); + } + } + + // Backspace: delete last char from query and re-search + (KeyCode::Backspace, _) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.pop_char(); + let q = fs.query.clone(); + let items = self.fuzzy_completer.search(&q, 100); + self.fuzzy_state.as_mut().unwrap().items = items; + } + } + + // Printable char: append to query and re-search + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.push_char(c); + let q = fs.query.clone(); + let items = self.fuzzy_completer.search(&q, 100); + self.fuzzy_state.as_mut().unwrap().items = items; + } + } + + // Any other key: close and re-dispatch + _ => { + self.fuzzy_state = None; + return Box::pin(self.handle_key(key)).await; + } + } + false + } + // ── Backslash commands ─────────────────────────────────────────────────── async fn handle_backslash_command(&mut self, cmd: &str) { @@ -1047,6 +1140,14 @@ impl TuiApp { } } + // Fuzzy search overlay (rendered on top of everything) + if let Some(fs) = &self.fuzzy_state { + let fuzzy_rect = fuzzy_popup::popup_area(layout.input, area); + if fuzzy_rect.height > 2 { + fuzzy_popup::render(fs, fuzzy_rect, f); + } + } + // Status bar self.render_status_bar(f, layout.status); } @@ -1181,8 +1282,12 @@ impl TuiApp { " Ctrl+C cancel ".to_string() } else if self.history_search.is_some() { " Enter accept Ctrl+R older Esc cancel ".to_string() + } else if self.fuzzy_state.is_some() { + " Enter accept ↑/↓ navigate Esc close ".to_string() + } else if self.completion_state.is_some() { + " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+D exit Ctrl+R search Ctrl+H help ".to_string() + " Ctrl+D exit Ctrl+Space fuzzy Tab complete ".to_string() }; // Pad between left and right From 94f2dc27a6f5383c6c36812a52b366f6c5cf907e Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:32:25 +0100 Subject: [PATCH 035/147] Fix csvlens viewer: propagate last_result, flash errors, keybind hints - Send ParsedResult back from query task via TuiMsg so self.context.last_result is populated after a query runs (was always None due to context clone) - Replace permanent output-pane error with a 2-second red flash in the status bar - Add Ctrl+V to the idle status bar hint line - Use keybind wording (Ctrl+V) instead of \view in all user-facing messages - Reorder \help to lead with keyboard shortcuts Co-Authored-By: Claude Sonnet 4.6 --- src/context.rs | 8 ++++++ src/query.rs | 9 ++++--- src/tui/mod.rs | 67 +++++++++++++++++++++++++++++++++----------------- src/tui_msg.rs | 2 ++ 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/context.rs b/src/context.rs index d8fdb79..157424c 100644 --- a/src/context.rs +++ b/src/context.rs @@ -127,6 +127,14 @@ impl Context { } } + /// Send the parsed result back to the TUI so it can be used by the csvlens viewer / export. + /// No-op in headless mode (caller already sets `context.last_result` directly). + pub fn emit_parsed_result(&self, result: &crate::table_renderer::ParsedResult) { + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::ParsedResult(result.clone())); + } + } + /// Returns `true` when running inside the TUI event loop. pub fn is_tui(&self) -> bool { self.tui_output_tx.is_some() diff --git a/src/query.rs b/src/query.rs index ced3341..9ca4fde 100644 --- a/src/query.rs +++ b/src/query.rs @@ -516,16 +516,18 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } } else { // Partial display was already emitted; show the final total - out_err!(context, "Showing {} of {} rows (use \\view to see all).", + out_err!(context, "Showing {} of {} rows (press Ctrl+V to see all).", format_number(display_rows.len() as u64), format_number(all_rows.len() as u64)); } - context.last_result = Some(ParsedResult { + let parsed_result = ParsedResult { columns: columns.clone(), rows: all_rows, statistics: statistics.clone(), errors: None, - }); + }; + context.emit_parsed_result(&parsed_result); + context.last_result = Some(parsed_result); context.last_stats = compute_stats(context.args.concise, &statistics); } @@ -539,6 +541,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if context.args.should_render_table() { match table_renderer::parse_jsonlines_compact(&body) { Ok(parsed) => { + context.emit_parsed_result(&parsed); context.last_result = Some(parsed.clone()); if let Some(errors) = parsed.errors { for error in errors { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a5581c0..94657cf 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -116,6 +116,9 @@ pub struct TuiApp { needs_clear: bool, should_quit: bool, pub has_error: bool, + + /// Temporary message shown in the status bar, with the time it was set. + flash_message: Option<(String, Instant)>, } impl TuiApp { @@ -159,6 +162,7 @@ impl TuiApp { needs_clear: false, should_quit: false, has_error: false, + flash_message: None, } } @@ -261,6 +265,9 @@ impl TuiApp { Ok(TuiMsg::Progress(n)) => { self.progress_rows = n; } + Ok(TuiMsg::ParsedResult(result)) => { + self.context.last_result = Some(result); + } Ok(TuiMsg::StyledLines(lines)) => { self.output.push_tui_lines(lines); } @@ -759,8 +766,7 @@ impl TuiApp { let result = match &self.context.last_result { Some(r) => r.clone(), None => { - self.output - .push_line("No query results to export. Run a query first."); + self.set_flash("No results to export — run a query first"); return; } }; @@ -957,8 +963,7 @@ impl TuiApp { fn open_viewer(&mut self) { if self.context.last_result.is_none() { - self.output - .push_line("No query results to display. Run a query first."); + self.set_flash("No results to display — run a query first"); return; } @@ -975,6 +980,11 @@ impl TuiApp { self.needs_clear = true; } + /// Show a temporary error message in the status bar for ~2 seconds. + fn set_flash(&mut self, msg: impl Into) { + self.flash_message = Some((msg.into(), Instant::now())); + } + fn do_refresh(&mut self) { if self.context.args.no_completion { self.output @@ -993,8 +1003,17 @@ impl TuiApp { fn show_help(&mut self) { self.output.push_ansi_text( - "Special commands:\n\ - \\view - Open last query result in csvlens viewer\n\ + "Keyboard shortcuts:\n\ + Ctrl+V - Open last result in csvlens viewer\n\ + Ctrl+R - Reverse history search\n\ + Ctrl+Space - Fuzzy schema search\n\ + Tab - Open / navigate completion popup\n\ + Shift+Tab - Navigate completion popup backwards\n\ + Ctrl+D - Exit\n\ + Ctrl+C - Cancel current input (or running query)\n\ + Page Up/Down - Scroll output\n\ + \n\ + Special commands:\n\ \\refresh - Manually refresh schema cache\n\ \\benchmark [N] - Run query N+1 times (1 warmup), show timing stats\n\ \\export [fmt] - Export last result to file (csv/tsv/json/jsonlines)\n\ @@ -1004,14 +1023,6 @@ impl TuiApp { set format = ; - Change output format\n\ unset format; - Reset format to default\n\ set completion = on/off; - Enable/disable auto-completion\n\ - \n\ - Keyboard shortcuts:\n\ - Tab - Open / navigate completion popup\n\ - Shift+Tab - Navigate completion popup backwards\n\ - Ctrl+R - Reverse history search\n\ - Ctrl+V - Open csvlens viewer for last result\n\ - Ctrl+D - Exit\n\ - Ctrl+C - Cancel current input (or running query)\n\ Ctrl+Up/Down - Cycle history\n\ Page Up/Down - Scroll output pane\n\ Shift+Enter - Insert newline without submitting", @@ -1273,21 +1284,32 @@ impl TuiApp { f.render_widget(Paragraph::new(vec![query_line, matched_line]), inner); } - fn render_status_bar(&self, f: &mut ratatui::Frame, area: Rect) { + fn render_status_bar(&mut self, f: &mut ratatui::Frame, area: Rect) { let host = &self.context.args.host; let db = &self.context.args.database; let left = format!(" {} | {} | v{}", host, db, CLI_VERSION); - let right = if self.is_running { - " Ctrl+C cancel ".to_string() + + // Expire flash messages older than 2 seconds. + if let Some((_, t)) = &self.flash_message { + if t.elapsed() >= Duration::from_secs(2) { + self.flash_message = None; + } + } + + let (right, style) = if let Some((msg, _)) = &self.flash_message { + let right = format!(" {} ", msg); + (right, Style::default().bg(Color::Red).fg(Color::White)) + } else if self.is_running { + (" Ctrl+C cancel ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) } else if self.history_search.is_some() { - " Enter accept Ctrl+R older Esc cancel ".to_string() + (" Enter accept Ctrl+R older Esc cancel ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) } else if self.fuzzy_state.is_some() { - " Enter accept ↑/↓ navigate Esc close ".to_string() + (" Enter accept ↑/↓ navigate Esc close ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) } else if self.completion_state.is_some() { - " Enter accept Tab/↑/↓ navigate Esc close ".to_string() + (" Enter accept Tab/↑/↓ navigate Esc close ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) } else { - " Ctrl+D exit Ctrl+Space fuzzy Tab complete ".to_string() + (" Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) }; // Pad between left and right @@ -1295,8 +1317,7 @@ impl TuiApp { let pad = total.saturating_sub(left.len() + right.len()); let status_text = format!("{}{}{}", left, " ".repeat(pad), right); - let status = Paragraph::new(status_text) - .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + let status = Paragraph::new(status_text).style(style); f.render_widget(status, area); } } diff --git a/src/tui_msg.rs b/src/tui_msg.rs index 6ec0328..37953ad 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -6,6 +6,8 @@ pub enum TuiMsg { StyledLines(Vec), /// Streaming progress update: total data rows received so far. Progress(u64), + /// The parsed result of the last successful SELECT query (for csvlens / export). + ParsedResult(crate::table_renderer::ParsedResult), } /// A single rendered line made up of zero or more styled spans. From b160255de1b66260d74f915d1eefb68cca403154 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:37:15 +0100 Subject: [PATCH 036/147] Mention both Ctrl+V and \view in truncation messages; flash error on left - "collecting remainder" message now says "Ctrl+V / \view" - "showing N of M rows" message now says "Ctrl+V or \view to see all" - Flash error shown on the left of the status bar in red; hint line stays on the right in the normal dark-grey style Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 4 ++-- src/tui/mod.rs | 36 ++++++++++++++++++++++-------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/query.rs b/src/query.rs index 9ca4fde..a69733e 100644 --- a/src/query.rs +++ b/src/query.rs @@ -462,7 +462,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let rendered = render_table_output(context, &columns, &display_rows, terminal_width, max_cell); out!(context, "{}", rendered); } - out_err!(context, "Showing first {} rows — collecting remainder for \\view...", + out_err!(context, "Showing first {} rows — collecting remainder for Ctrl+V / \\view...", format_number(display_rows.len() as u64)); display_emitted = true; } else { @@ -516,7 +516,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } } else { // Partial display was already emitted; show the final total - out_err!(context, "Showing {} of {} rows (press Ctrl+V to see all).", + out_err!(context, "Showing {} of {} rows (press Ctrl+V or \\view to see all).", format_number(display_rows.len() as u64), format_number(all_rows.len() as u64)); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 94657cf..c267ea5 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1288,7 +1288,7 @@ impl TuiApp { let host = &self.context.args.host; let db = &self.context.args.database; - let left = format!(" {} | {} | v{}", host, db, CLI_VERSION); + let conn_info = format!(" {} | {} | v{}", host, db, CLI_VERSION); // Expire flash messages older than 2 seconds. if let Some((_, t)) = &self.flash_message { @@ -1297,27 +1297,35 @@ impl TuiApp { } } - let (right, style) = if let Some((msg, _)) = &self.flash_message { - let right = format!(" {} ", msg); - (right, Style::default().bg(Color::Red).fg(Color::White)) - } else if self.is_running { - (" Ctrl+C cancel ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) + let right = if self.is_running { + " Ctrl+C cancel ".to_string() } else if self.history_search.is_some() { - (" Enter accept Ctrl+R older Esc cancel ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) + " Enter accept Ctrl+R older Esc cancel ".to_string() } else if self.fuzzy_state.is_some() { - (" Enter accept ↑/↓ navigate Esc close ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) + " Enter accept ↑/↓ navigate Esc close ".to_string() } else if self.completion_state.is_some() { - (" Enter accept Tab/↑/↓ navigate Esc close ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) + " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - (" Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete ".to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)) + " Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete ".to_string() }; - // Pad between left and right let total = area.width as usize; - let pad = total.saturating_sub(left.len() + right.len()); - let status_text = format!("{}{}{}", left, " ".repeat(pad), right); - let status = Paragraph::new(status_text).style(style); + let status = if let Some((msg, _)) = &self.flash_message { + // Flash: error on the left in red, hint line on the right in normal style. + let flash_left = format!(" {} ", msg); + let pad = total.saturating_sub(flash_left.len() + right.len()); + let gap = " ".repeat(pad); + let spans: Vec = vec![ + Span::styled(flash_left + &gap, Style::default().bg(Color::Red).fg(Color::White)), + Span::styled(right, Style::default().bg(Color::DarkGray).fg(Color::White)), + ]; + Paragraph::new(Line::from(spans)) + } else { + let pad = total.saturating_sub(conn_info.len() + right.len()); + let text = format!("{}{}{}", conn_info, " ".repeat(pad), right); + Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)) + }; f.render_widget(status, area); } } From fdbe6a911c2b8b34ca57ce08ff4684f8af864805 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:41:29 +0100 Subject: [PATCH 037/147] Fix Ctrl+V viewer: proper terminal teardown and format guard bypass - Disable mouse capture and keyboard enhancement before handing terminal to csvlens (lingering state caused csvlens to fail silently) - Restore those flags after csvlens exits - Temporarily force client:auto format so open_csvlens_viewer's format guard doesn't block the call in --core / PSQL format mode (the TUI always has a ParsedResult available regardless of server format) - Route viewer errors through set_flash() so they appear in the status bar instead of being swallowed by eprintln! Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c267ea5..a64e608 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -967,17 +967,38 @@ impl TuiApp { return; } - // Temporarily leave the TUI to let csvlens take over the terminal. + // In TUI mode the viewer always has parsed data available, so bypass the + // format guard in open_csvlens_viewer by temporarily setting client:auto. + let saved_format = self.context.args.format.clone(); + if !self.context.args.format.starts_with("client:") { + self.context.args.format = "client:auto".to_string(); + } + + // Suspend the TUI and hand the terminal to csvlens. let _ = disable_raw_mode(); - let _ = execute!(std::io::stdout(), LeaveAlternateScreen); + let _ = execute!( + std::io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture, + PopKeyboardEnhancementFlags + ); - if let Err(e) = open_csvlens_viewer(&self.context) { - eprintln!("Error: {}", e); - } + let result = open_csvlens_viewer(&self.context); + let _ = execute!( + std::io::stdout(), + EnterAlternateScreen, + EnableMouseCapture, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); let _ = enable_raw_mode(); - let _ = execute!(std::io::stdout(), EnterAlternateScreen); + + self.context.args.format = saved_format; self.needs_clear = true; + + if let Err(e) = result { + self.set_flash(format!("Viewer error: {}", e)); + } } /// Show a temporary error message in the status bar for ~2 seconds. From cc35cc07d8579a0b40bbf5cec2ba38acdfbf5ee2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:51:18 +0100 Subject: [PATCH 038/147] Fix csvlens viewer: pending-action pattern + revert format bypass crossterm uses a global static InternalEventReader (parking_lot Mutex). Calling csvlens from inside handle_key meant the reader could be in an intermediate state (Kitty protocol parsing, buffered events), causing csvlens to mis-read input and exit immediately. Fix: set a pending_viewer flag in handle_key; the actual viewer launch happens at the TOP of event_loop before the next event::poll, so the global reader is fully idle when csvlens takes it over. Also: - Revert the format-bypass hack; instead show a clear flash message if the format is not client:*, telling the user how to fix it - Call terminal.clear() directly in run_viewer after restoring the TUI (more reliable than the needs_clear flag) - Flush stdout before handing off to csvlens Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a64e608..6da9c8a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -119,6 +119,10 @@ pub struct TuiApp { /// Temporary message shown in the status bar, with the time it was set. flash_message: Option<(String, Instant)>, + + /// When true, open the csvlens viewer at the top of the next event-loop tick + /// (outside any event-handler so crossterm's global reader is fully idle). + pending_viewer: bool, } impl TuiApp { @@ -163,6 +167,7 @@ impl TuiApp { should_quit: false, has_error: false, flash_message: None, + pending_viewer: false, } } @@ -212,6 +217,13 @@ impl TuiApp { terminal: &mut Terminal>, ) -> Result> { loop { + // ── Launch csvlens here, before event::poll, so crossterm's global + // reader is fully idle (not inside any event handler). + if self.pending_viewer { + self.pending_viewer = false; + self.run_viewer(terminal); + } + self.drain_query_output(); if self.is_running { @@ -961,20 +973,24 @@ impl TuiApp { )); } + /// Called from handle_key: validates that data is available, then queues + /// the viewer to open at the top of the next event-loop iteration. fn open_viewer(&mut self) { if self.context.last_result.is_none() { self.set_flash("No results to display — run a query first"); return; } - - // In TUI mode the viewer always has parsed data available, so bypass the - // format guard in open_csvlens_viewer by temporarily setting client:auto. - let saved_format = self.context.args.format.clone(); if !self.context.args.format.starts_with("client:") { - self.context.args.format = "client:auto".to_string(); + self.set_flash("Viewer requires client format — run: set format = client:auto;"); + return; } + self.pending_viewer = true; + } - // Suspend the TUI and hand the terminal to csvlens. + /// Actually launches csvlens. Called at the top of event_loop, outside any + /// event-handler, so crossterm's global InternalEventReader is fully idle. + fn run_viewer(&mut self, terminal: &mut Terminal>) { + // Suspend the TUI: hand the terminal back to the normal screen. let _ = disable_raw_mode(); let _ = execute!( std::io::stdout(), @@ -982,9 +998,12 @@ impl TuiApp { DisableMouseCapture, PopKeyboardEnhancementFlags ); + // Flush so all escape sequences are sent before csvlens takes over. + let _ = std::io::Write::flush(&mut std::io::stdout()); let result = open_csvlens_viewer(&self.context); + // Restore the TUI. let _ = execute!( std::io::stdout(), EnterAlternateScreen, @@ -992,9 +1011,9 @@ impl TuiApp { PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) ); let _ = enable_raw_mode(); - - self.context.args.format = saved_format; - self.needs_clear = true; + // Force a full redraw so ratatui's internal buffer matches reality. + let _ = terminal.clear(); + self.needs_clear = false; // clear() already handled it if let Err(e) = result { self.set_flash(format!("Viewer error: {}", e)); From 2bd0e90144fb1efe237042b9adddb211bd7f2718 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 17:59:09 +0100 Subject: [PATCH 039/147] Fix run_viewer: use terminal.backend_mut() for all terminal escape sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ratatui's CrosstermBackend wraps stdout in a BufWriter. Writing escape sequences to std::io::stdout() directly uses a different buffer, so the teardown/restore sequences could be interleaved incorrectly with ratatui's own buffered output. Now mirrors run() exactly: - Teardown: disable_raw_mode → PopKeyboard → LeaveAltScreen+DisableMouse → flush - Restore: EnterAltScreen+EnableMouse → PushKeyboard → enable_raw_mode → flush → clear Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6da9c8a..0285204 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -990,30 +990,25 @@ impl TuiApp { /// Actually launches csvlens. Called at the top of event_loop, outside any /// event-handler, so crossterm's global InternalEventReader is fully idle. fn run_viewer(&mut self, terminal: &mut Terminal>) { - // Suspend the TUI: hand the terminal back to the normal screen. + // Mirror exactly what run() does on exit: tear down raw mode + alt screen + // through the ratatui backend's BufWriter so everything is flushed in order. let _ = disable_raw_mode(); - let _ = execute!( - std::io::stdout(), - LeaveAlternateScreen, - DisableMouseCapture, - PopKeyboardEnhancementFlags - ); - // Flush so all escape sequences are sent before csvlens takes over. - let _ = std::io::Write::flush(&mut std::io::stdout()); + let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); + let _ = std::io::Write::flush(terminal.backend_mut()); let result = open_csvlens_viewer(&self.context); - // Restore the TUI. + // Mirror exactly what run() does on entry: restore raw mode + alt screen. + let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); let _ = execute!( - std::io::stdout(), - EnterAlternateScreen, - EnableMouseCapture, + terminal.backend_mut(), PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) ); let _ = enable_raw_mode(); - // Force a full redraw so ratatui's internal buffer matches reality. + // Flush the backend and force ratatui to redraw everything from scratch. + let _ = std::io::Write::flush(terminal.backend_mut()); let _ = terminal.clear(); - self.needs_clear = false; // clear() already handled it if let Err(e) = result { self.set_flash(format!("Viewer error: {}", e)); From 670c15e1c4d507277142b011c61296dd07e24a1b Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:10:34 +0100 Subject: [PATCH 040/147] Remove stale comment in run_viewer Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0285204..05f6736 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1006,7 +1006,6 @@ impl TuiApp { PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) ); let _ = enable_raw_mode(); - // Flush the backend and force ratatui to redraw everything from scratch. let _ = std::io::Write::flush(terminal.backend_mut()); let _ = terminal.clear(); From c6a5df5959abff7f52e7c556b9ada47a35b57b39 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:21:49 +0100 Subject: [PATCH 041/147] Fix tab completion doubling: jump to word start before deleting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tui-textarea's delete_str(n) deletes FORWARD from the cursor, but the cursor sits at the END of the partial word — so nothing was deleted and the full completion value was appended, producing output like: "information_schema.accoinformation_schema.account_info" Fix: move_cursor(Jump) to the word-start position first, then delete_str(partial_len) removes the partial word forward, then insert_str adds the chosen completion. Applies to both the single-item fast path and the popup accept path. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 05f6736..389f1ef 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -588,6 +588,9 @@ impl TuiApp { if items.len() == 1 { let value = items.into_iter().next().unwrap().value; let partial_len = cursor_col - word_start_byte_in_line; + // Jump to word start, then delete the partial word forward, then insert. + // (delete_str deletes FORWARD from cursor, so we must reposition first.) + self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, word_start_byte_in_line as u16)); self.textarea.delete_str(partial_len); self.textarea.insert_str(&value); return; @@ -609,8 +612,11 @@ impl TuiApp { None => return, }; - let (_, cursor_col) = self.textarea.cursor(); + let (cursor_row, cursor_col) = self.textarea.cursor(); let partial_len = cursor_col - cs.word_start_col; + // Jump to word start, then delete the partial word forward, then insert. + // (delete_str deletes FORWARD from cursor, so we must reposition first.) + self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, cs.word_start_col as u16)); self.textarea.delete_str(partial_len); self.textarea.insert_str(&selected); } From d6dd3334672e05500004adfe74bba69be9c4e2fe Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:26:37 +0100 Subject: [PATCH 042/147] Show table name next to columns in tab completion popup Instead of a generic "column" label, each column candidate now shows the table it belongs to (e.g. "orders") in the description column. The popup description column is widened from 8 to 16 chars to fit longer table names. Table items still show "table", functions show "func". Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 19 ++++++++++++++----- src/completion/priority_scorer.rs | 2 ++ src/tui/completion_popup.rs | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 345f7a8..7a64e2f 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -91,6 +91,7 @@ impl SqlCompleter { name: qualified_name, item_type: ItemType::Table, score, + table_name: None, }); } } else { @@ -116,6 +117,7 @@ impl SqlCompleter { name: schema_with_dot, item_type: ItemType::Table, score, + table_name: None, }); } } @@ -131,6 +133,7 @@ impl SqlCompleter { name: short_name.clone(), item_type: ItemType::Table, score, + table_name: None, }); } @@ -141,6 +144,7 @@ impl SqlCompleter { name: qualified_name, item_type: ItemType::Table, score, + table_name: None, }); } } @@ -163,6 +167,7 @@ impl SqlCompleter { name: short_name.clone(), item_type: ItemType::Column, score, + table_name: table.clone(), }); } @@ -175,6 +180,7 @@ impl SqlCompleter { name: qualified_name, item_type: ItemType::Column, score: score.saturating_sub(1), + table_name: Some(tbl), }); } } @@ -193,6 +199,7 @@ impl SqlCompleter { name: function_with_paren, item_type: ItemType::Column, score, + table_name: None, }); } } @@ -208,11 +215,13 @@ impl SqlCompleter { .filter(|s| seen.insert(s.name.clone())) .map(|s| { let description = match s.item_type { - ItemType::Table => "table", - ItemType::Column => "column", - ItemType::Function => "function", - } - .to_string(); + ItemType::Table => "table".to_string(), + ItemType::Column => match s.table_name { + Some(t) => t, + None => "column".to_string(), + }, + ItemType::Function => "func".to_string(), + }; CompletionItem { value: s.name, description, diff --git a/src/completion/priority_scorer.rs b/src/completion/priority_scorer.rs index a3f26f0..3d413e2 100644 --- a/src/completion/priority_scorer.rs +++ b/src/completion/priority_scorer.rs @@ -13,6 +13,8 @@ pub struct ScoredSuggestion { pub name: String, pub item_type: ItemType, pub score: u32, + /// For column items: the table the column belongs to (short name, no schema prefix). + pub table_name: Option, } /// Priority classes determine the base score before usage bonuses diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index 5da1af3..16e08bb 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -68,7 +68,7 @@ impl CompletionState { // ── Layout helpers ─────────────────────────────────────────────────────────── const MAX_VISIBLE: u16 = 10; -const DESCRIPTION_WIDTH: u16 = 8; // " table " / " column " / " function" +const DESCRIPTION_WIDTH: u16 = 16; // " table" / " orders" / " func" const MIN_VALUE_WIDTH: u16 = 16; const BORDER_OVERHEAD: u16 = 2; // left + right border From d262890fc67f22c9c0265e4a779e8fbd5a474d93 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:28:58 +0100 Subject: [PATCH 043/147] Fix popup scrolling: track scroll_offset, keep selection visible Both the tab-completion and fuzzy popups rendered all items as a flat list with no viewport tracking, so navigating past the visible area caused the highlight to disappear off-screen. Add scroll_offset to CompletionState and FuzzyState. next()/prev() call ensure_visible() which adjusts scroll_offset so the selected row is always within the MAX_VISIBLE window. render() now skips scroll_offset items and takes only the visible slice. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/completion_popup.rs | 23 ++++++++++++++++++++--- src/tui/fuzzy_popup.rs | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index 16e08bb..7efcd87 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -17,6 +17,8 @@ pub struct CompletionState { pub items: Vec, /// Index of the currently highlighted item. pub selected: usize, + /// Index of the first visible item (scroll position). + pub scroll_offset: usize, /// Byte offset in the full textarea content where the partial word starts. pub word_start_byte: usize, /// Visual column (0-based) of the word start within its line. @@ -36,6 +38,7 @@ impl CompletionState { Self { items, selected: 0, + scroll_offset: 0, word_start_byte, word_start_col, cursor_row, @@ -50,17 +53,29 @@ impl CompletionState { self.items.get(self.selected) } - /// Advance selection to the next item (wrapping). + /// Advance selection to the next item (wrapping), scrolling the viewport. pub fn next(&mut self) { if !self.items.is_empty() { self.selected = (self.selected + 1) % self.items.len(); + self.ensure_visible(); } } - /// Move selection to the previous item (wrapping). + /// Move selection to the previous item (wrapping), scrolling the viewport. pub fn prev(&mut self) { if !self.items.is_empty() { self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1); + self.ensure_visible(); + } + } + + /// Adjust scroll_offset so that `selected` is within the visible window. + fn ensure_visible(&mut self) { + let vis = MAX_VISIBLE as usize; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + vis { + self.scroll_offset = self.selected - vis + 1; } } } @@ -126,11 +141,13 @@ pub fn render(state: &CompletionState, area: Rect, f: &mut ratatui::Frame) { let inner = block.inner(area); let value_width = inner.width.saturating_sub(DESCRIPTION_WIDTH) as usize; - // Build list items + // Build list items — only the visible window starting at scroll_offset let items: Vec = state .items .iter() .enumerate() + .skip(state.scroll_offset) + .take(inner.height as usize) .map(|(idx, item)| { let is_selected = idx == state.selected; diff --git a/src/tui/fuzzy_popup.rs b/src/tui/fuzzy_popup.rs index 9fe5222..d5cfe82 100644 --- a/src/tui/fuzzy_popup.rs +++ b/src/tui/fuzzy_popup.rs @@ -22,6 +22,8 @@ pub struct FuzzyState { pub items: Vec, /// Currently highlighted item index. pub selected: usize, + /// Index of the first visible item (scroll position). + pub scroll_offset: usize, } impl FuzzyState { @@ -30,34 +32,49 @@ impl FuzzyState { query: String::new(), items: Vec::new(), selected: 0, + scroll_offset: 0, } } pub fn push_char(&mut self, c: char) { self.query.push(c); self.selected = 0; + self.scroll_offset = 0; } pub fn pop_char(&mut self) { self.query.pop(); self.selected = 0; + self.scroll_offset = 0; } pub fn next(&mut self) { if !self.items.is_empty() { self.selected = (self.selected + 1) % self.items.len(); + self.ensure_visible(); } } pub fn prev(&mut self) { if !self.items.is_empty() { self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1); + self.ensure_visible(); } } pub fn selected_item(&self) -> Option<&FuzzyItem> { self.items.get(self.selected) } + + /// Adjust scroll_offset so that `selected` is within the visible window. + fn ensure_visible(&mut self) { + let vis = MAX_VISIBLE as usize; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + vis { + self.scroll_offset = self.selected - vis + 1; + } + } } /// Compute the popup rect: full-width, anchored just above the input pane. @@ -102,6 +119,8 @@ pub fn render(state: &FuzzyState, area: Rect, f: &mut ratatui::Frame) { .items .iter() .enumerate() + .skip(state.scroll_offset) + .take(chunks[1].height as usize) .map(|(idx, item)| { let is_sel = idx == state.selected; From d53147b01031c915897ed46a126d69853625fb75 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:33:16 +0100 Subject: [PATCH 044/147] Show type labels in completion popup: column/table/schema/function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the previous "show table name for columns" approach. The description column now shows one of four labels based on item type: column → "column" table → "table" schema → "schema" (new ItemType::Schema, previously mislabeled as Table) function → "function" Also removes table_name from ScoredSuggestion (no longer needed) and adjusts DESCRIPTION_WIDTH to 10 to comfortably fit "function". Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 24 ++++++++---------------- src/completion/priority_scorer.rs | 4 +--- src/completion/usage_tracker.rs | 2 ++ src/tui/completion_popup.rs | 2 +- 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 7a64e2f..9ca05ae 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -91,7 +91,6 @@ impl SqlCompleter { name: qualified_name, item_type: ItemType::Table, score, - table_name: None, }); } } else { @@ -115,9 +114,8 @@ impl SqlCompleter { scored.push(ScoredSuggestion { name: schema_with_dot, - item_type: ItemType::Table, + item_type: ItemType::Schema, score, - table_name: None, }); } } @@ -133,7 +131,6 @@ impl SqlCompleter { name: short_name.clone(), item_type: ItemType::Table, score, - table_name: None, }); } @@ -144,7 +141,6 @@ impl SqlCompleter { name: qualified_name, item_type: ItemType::Table, score, - table_name: None, }); } } @@ -167,7 +163,6 @@ impl SqlCompleter { name: short_name.clone(), item_type: ItemType::Column, score, - table_name: table.clone(), }); } @@ -180,7 +175,6 @@ impl SqlCompleter { name: qualified_name, item_type: ItemType::Column, score: score.saturating_sub(1), - table_name: Some(tbl), }); } } @@ -197,9 +191,8 @@ impl SqlCompleter { scored.push(ScoredSuggestion { name: function_with_paren, - item_type: ItemType::Column, + item_type: ItemType::Function, score, - table_name: None, }); } } @@ -215,13 +208,12 @@ impl SqlCompleter { .filter(|s| seen.insert(s.name.clone())) .map(|s| { let description = match s.item_type { - ItemType::Table => "table".to_string(), - ItemType::Column => match s.table_name { - Some(t) => t, - None => "column".to_string(), - }, - ItemType::Function => "func".to_string(), - }; + ItemType::Table => "table", + ItemType::Column => "column", + ItemType::Function => "function", + ItemType::Schema => "schema", + } + .to_string(); CompletionItem { value: s.name, description, diff --git a/src/completion/priority_scorer.rs b/src/completion/priority_scorer.rs index 3d413e2..2d74965 100644 --- a/src/completion/priority_scorer.rs +++ b/src/completion/priority_scorer.rs @@ -13,8 +13,6 @@ pub struct ScoredSuggestion { pub name: String, pub item_type: ItemType, pub score: u32, - /// For column items: the table the column belongs to (short name, no schema prefix). - pub table_name: Option, } /// Priority classes determine the base score before usage bonuses @@ -92,7 +90,7 @@ impl PriorityScorer { } match item_type { - ItemType::Table => PriorityClass::Table, + ItemType::Table | ItemType::Schema => PriorityClass::Table, ItemType::Column => { // Check if column belongs to a table in the statement diff --git a/src/completion/usage_tracker.rs b/src/completion/usage_tracker.rs index 786d895..f30ee71 100644 --- a/src/completion/usage_tracker.rs +++ b/src/completion/usage_tracker.rs @@ -6,6 +6,7 @@ pub enum ItemType { Table, Column, Function, + Schema, } /// Tracks usage frequency of tables, columns, and functions to enable intelligent prioritization @@ -78,6 +79,7 @@ impl UsageTracker { let counts = self.function_counts.read().unwrap(); counts.get(name).copied().unwrap_or(0) } + ItemType::Schema => 0, } } diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index 7efcd87..7dc31e0 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -83,7 +83,7 @@ impl CompletionState { // ── Layout helpers ─────────────────────────────────────────────────────────── const MAX_VISIBLE: u16 = 10; -const DESCRIPTION_WIDTH: u16 = 16; // " table" / " orders" / " func" +const DESCRIPTION_WIDTH: u16 = 10; // "table" / "column" / "function" / "schema" const MIN_VALUE_WIDTH: u16 = 16; const BORDER_OVERHEAD: u16 = 2; // left + right border From f5279663576875c38094c6b22ffaa4b01d08ab67 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:39:49 +0100 Subject: [PATCH 045/147] Always show columns as table.column; match on column name prefix too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare column completions (without table qualifier) are removed. Only "table.column" qualified forms are shown. The match condition is broadened: when the user types just a column name prefix (e.g. "acco"), qualified entries like "orders.account_id" whose column part starts with the prefix are included — with the same high priority (5000) they would have had when the table is in the current query. Non-query-table columns are now scored using the qualified name ("orders.account_id" contains a dot) so they rank at QualifiedColumnOtherTable (3000) instead of Unqualified (2000). Columns with no associated table name continue to be skipped. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 9ca05ae..fe07132 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -146,35 +146,27 @@ impl SqlCompleter { } for (table, column) in column_metadata { - let short_name = column.clone(); - - let is_qualifying_table = partial.contains('.') && - table.as_ref().map_or(false, |t| partial.to_lowercase().starts_with(&format!("{}.", t.to_lowercase()))); - - let score = self.scorer.score( - &short_name, - ItemType::Column, - &tables_in_line, - table.as_deref(), - ); - - if !is_qualifying_table && short_name.to_lowercase().starts_with(&partial_lower) { - scored.push(ScoredSuggestion { - name: short_name.clone(), - item_type: ItemType::Column, - score, - }); - } - + // Only emit qualified "table.column" candidates; bare column names are skipped. if let Some(tbl) = table { let qualified_name = format!("{}.{}", tbl, column); - - if qualified_name.to_lowercase().starts_with(&partial_lower) && - (tables_in_line.is_empty() || partial.contains('.')) { + let qualified_lower = qualified_name.to_lowercase(); + let col_lower = column.to_lowercase(); + + // Match when the user types just the column name prefix ("acco") OR + // the qualified prefix ("orders.acc"). + if col_lower.starts_with(&partial_lower) || qualified_lower.starts_with(&partial_lower) { + // Score with the qualified name so non-query-table columns rank as + // QualifiedColumnOtherTable (3000) rather than Unqualified (2000). + let score = self.scorer.score( + &qualified_name, + ItemType::Column, + &tables_in_line, + Some(&tbl), + ); scored.push(ScoredSuggestion { name: qualified_name, item_type: ItemType::Column, - score: score.saturating_sub(1), + score, }); } } From 73a5b7e4bb716f264e1b443359d804b4cf8e4cf5 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:41:45 +0100 Subject: [PATCH 046/147] Only suggest columns from tables mentioned in the current SQL Column completions are now suppressed entirely when the column's table does not appear anywhere in the current SQL text. Previously such columns would still show up ranked at priority 3000. The table membership check mirrors the one already used by the priority scorer (handles schema-qualified vs unqualified name variants). Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index fe07132..f42dc11 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -146,8 +146,20 @@ impl SqlCompleter { } for (table, column) in column_metadata { - // Only emit qualified "table.column" candidates; bare column names are skipped. + // Only emit qualified "table.column" candidates for tables that appear in + // the current SQL text. Columns from unmentioned tables are skipped. if let Some(tbl) = table { + let tbl_lower = tbl.to_lowercase(); + let table_in_query = tables_in_line.iter().any(|t| { + let t_lower = t.to_lowercase(); + t_lower == tbl_lower + || t_lower.ends_with(&format!(".{}", tbl_lower)) + || tbl_lower.ends_with(&format!(".{}", t_lower)) + }); + if !table_in_query { + continue; + } + let qualified_name = format!("{}.{}", tbl, column); let qualified_lower = qualified_name.to_lowercase(); let col_lower = column.to_lowercase(); @@ -155,8 +167,6 @@ impl SqlCompleter { // Match when the user types just the column name prefix ("acco") OR // the qualified prefix ("orders.acc"). if col_lower.starts_with(&partial_lower) || qualified_lower.starts_with(&partial_lower) { - // Score with the qualified name so non-query-table columns rank as - // QualifiedColumnOtherTable (3000) rather than Unqualified (2000). let score = self.scorer.score( &qualified_name, ItemType::Column, From 4844f3671bf7fca2e11483a1a1c6302aa04a3a06 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:45:49 +0100 Subject: [PATCH 047/147] Fix column completion after "table." prefix When the user types "test." with "test" referenced in the FROM clause, the dot caused complete_at() to enter the schema-filter branch (looking for tables in schema "test") rather than the column-suggestion branch. Fix: before trying schema-table lookup, check whether the part before the dot is a table mentioned in the current query. If it is, emit table.column candidates whose column names start with the typed suffix, exactly as the non-dot path does. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 56 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index f42dc11..1d43fef 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -81,17 +81,51 @@ impl SqlCompleter { }; if let Some(schema_name) = schema_filter { - let tables_in_schema = self.cache.get_tables_in_schema(schema_name, table_prefix); - - for table in tables_in_schema { - let qualified_name = format!("{}.{}", schema_name, table); - let score = self.scorer.score(&table, ItemType::Table, &tables_in_line, None); - - scored.push(ScoredSuggestion { - name: qualified_name, - item_type: ItemType::Table, - score, - }); + // If the part before the dot is a table referenced in the query, treat it as a + // table name and suggest its columns (e.g. "test." → "test.val"). + let schema_lower = schema_name.to_lowercase(); + let is_query_table = tables_in_line.iter().any(|t| { + let t_lower = t.to_lowercase(); + t_lower == schema_lower + || t_lower.ends_with(&format!(".{}", schema_lower)) + || schema_lower.ends_with(&format!(".{}", t_lower)) + }); + + if is_query_table { + let col_prefix_lower = table_prefix.to_lowercase(); + let column_metadata = self.cache.get_columns_with_table(partial); + for (table, column) in column_metadata { + if let Some(tbl) = table { + if tbl.to_lowercase() == schema_lower + && column.to_lowercase().starts_with(&col_prefix_lower) + { + let qualified_name = format!("{}.{}", tbl, column); + let score = self.scorer.score( + &qualified_name, + ItemType::Column, + &tables_in_line, + Some(&tbl), + ); + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Column, + score, + }); + } + } + } + } else { + // Part before the dot is a schema name — suggest tables within it. + let tables_in_schema = self.cache.get_tables_in_schema(schema_name, table_prefix); + for table in tables_in_schema { + let qualified_name = format!("{}.{}", schema_name, table); + let score = self.scorer.score(&table, ItemType::Table, &tables_in_line, None); + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Table, + score, + }); + } } } else { let table_metadata = self.cache.get_tables_with_schema(partial); From 439963eb029360161d10e904213bb9468b29e4af Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:47:10 +0100 Subject: [PATCH 048/147] Prioritize public-schema tables over other schemas in tab completion Tables in the public (or empty) schema get a +500 score bonus, placing them at 4500 vs 4000 for non-system tables in other schemas. This ensures public tables appear first when the user types a partial name that matches tables in multiple schemas. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 1d43fef..8a70f72 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -158,7 +158,13 @@ impl SqlCompleter { let short_name = table.clone(); let qualified_name = format!("{}.{}", schema, table); - let score = self.scorer.score(&short_name, ItemType::Table, &tables_in_line, None); + let base_score = self.scorer.score(&short_name, ItemType::Table, &tables_in_line, None); + // Public-schema tables rank 500 points above other non-system schemas. + let score = if schema == "public" || schema.is_empty() { + base_score + 500 + } else { + base_score + }; if short_name.to_lowercase().starts_with(&partial_lower) { scored.push(ScoredSuggestion { From 12fa06af84c5fc1f3e4e900b897d8d4d46045529 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:47:53 +0100 Subject: [PATCH 049/147] Non-public tables always suggest as schema.table, match on table prefix too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-schema tables continue to show as bare names (e.g. "orders"). Non-public tables now only appear as "schema.table" — never as bare table names. Like columns, they match on either just the table-name prefix ("ord" → "myschema.orders") or the full qualified prefix ("myschema.ord" → "myschema.orders"). Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 47 +++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 8a70f72..8cb11f1 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -157,31 +157,34 @@ impl SqlCompleter { for (schema, table) in table_metadata { let short_name = table.clone(); let qualified_name = format!("{}.{}", schema, table); + let short_lower = short_name.to_lowercase(); + let qualified_lower = qualified_name.to_lowercase(); let base_score = self.scorer.score(&short_name, ItemType::Table, &tables_in_line, None); - // Public-schema tables rank 500 points above other non-system schemas. - let score = if schema == "public" || schema.is_empty() { - base_score + 500 - } else { - base_score - }; - - if short_name.to_lowercase().starts_with(&partial_lower) { - scored.push(ScoredSuggestion { - name: short_name.clone(), - item_type: ItemType::Table, - score, - }); - } - if qualified_name.to_lowercase().starts_with(&partial_lower) && - (schema != "public" || partial.contains('.')) && - !partial_matches_schema { - scored.push(ScoredSuggestion { - name: qualified_name, - item_type: ItemType::Table, - score, - }); + if schema == "public" || schema.is_empty() { + // Public tables: show as bare name; rank 500 above non-public. + if short_lower.starts_with(&partial_lower) { + scored.push(ScoredSuggestion { + name: short_name.clone(), + item_type: ItemType::Table, + score: base_score + 500, + }); + } + } else { + // Non-public tables: always show as schema.table. + // Match when typing just the table name prefix ("ord") OR + // the full qualified prefix ("myschema.ord"). + if (short_lower.starts_with(&partial_lower) + || qualified_lower.starts_with(&partial_lower)) + && !partial_matches_schema + { + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Table, + score: base_score, + }); + } } } From 581f7b8d4029c8edf2abc5b8f057f8f1f8fdb166 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 18:54:29 +0100 Subject: [PATCH 050/147] Align fuzzy search with tab-completion conventions - Descriptions: now "table" / "column" / "function" matching the popup - Column labels/inserts: always qualified as table.column (public schema) or schema.table.column (non-public); bare column names removed - Table labels: bare name for public, schema.table for non-public - Sorting: priority tier (public table=3, non-public table=2, column=1, function=0) used as tiebreaker after fuzzy score, then alphabetical - get_all_columns now returns (schema, table, column) triples Co-Authored-By: Claude Sonnet 4.6 --- src/completion/fuzzy_completer.rs | 61 ++++++++++++++++++------------- src/completion/schema_cache.rs | 12 +++--- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/completion/fuzzy_completer.rs b/src/completion/fuzzy_completer.rs index 6a7a21c..c8e38db 100644 --- a/src/completion/fuzzy_completer.rs +++ b/src/completion/fuzzy_completer.rs @@ -23,26 +23,22 @@ impl FuzzyCompleter { } } - /// Return all schema items that fuzzy-match `query`, ranked by score. + /// Return all schema items that fuzzy-match `query`, ranked by score then priority. /// Returns up to `limit` items. pub fn search(&mut self, query: &str, limit: usize) -> Vec { let all_items = self.collect_all_items(); if query.is_empty() { - // Show first `limit` items sorted alphabetically let mut items = all_items; - items.sort_by(|a, b| a.label.cmp(&b.label)); + items.sort_by(|a, b| { + b.priority.cmp(&a.priority).then(a.label.cmp(&b.label)) + }); items.truncate(limit); return items; } - let pattern = Pattern::parse( - query, - CaseMatching::Ignore, - Normalization::Smart, - ); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); - // Score every item and keep matches with score > 0 let mut scored: Vec<(u32, FuzzyItem)> = all_items .into_iter() .filter_map(|item| { @@ -52,8 +48,11 @@ impl FuzzyCompleter { }) .collect(); - // Sort by score descending - scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.sort_by(|a, b| { + b.0.cmp(&a.0) + .then(b.1.priority.cmp(&a.1.priority)) + .then(a.1.label.cmp(&b.1.label)) + }); scored.truncate(limit); scored.into_iter().map(|(_, item)| item).collect() } @@ -62,42 +61,49 @@ impl FuzzyCompleter { fn collect_all_items(&self) -> Vec { let mut items = Vec::new(); - // All tables (with schema prefix for non-public) + // Tables for (schema, table) in self.cache.get_all_tables() { - let label = if schema == "public" { + let is_public = schema == "public" || schema.is_empty(); + let label = if is_public { table.clone() } else { format!("{}.{}", schema, table) }; items.push(FuzzyItem { + insert_value: label.clone(), label, - description: format!("table ({})", schema), + description: "table".to_string(), item_type: ItemType::Table, - insert_value: if schema == "public" { - table - } else { - format!("{}.{}", schema, table) - }, + // Public tables rank above non-public tables. + priority: if is_public { 3 } else { 2 }, }); } - // All columns (unqualified; qualified duplicates would be too noisy) - for (table, column) in self.cache.get_all_columns() { + // Columns — always shown as table.column (or schema.table.column for non-public). + for (schema, table, column) in self.cache.get_all_columns() { + let is_public = schema == "public" || schema.is_empty(); + let label = if is_public { + format!("{}.{}", table, column) + } else { + format!("{}.{}.{}", schema, table, column) + }; items.push(FuzzyItem { - label: format!("{}.{}", table, column), - description: format!("column of {}", table), + insert_value: label.clone(), + label, + description: "column".to_string(), item_type: ItemType::Column, - insert_value: column, + priority: 1, }); } - // All functions + // Functions for function in self.cache.get_all_functions() { items.push(FuzzyItem { label: format!("{}()", function), description: "function".to_string(), item_type: ItemType::Function, insert_value: format!("{}(", function), + priority: 0, }); } @@ -110,10 +116,13 @@ impl FuzzyCompleter { pub struct FuzzyItem { /// Displayed text in the result list. pub label: String, - /// Short description (type, schema). + /// Short description matching tab-completion labels: "table" / "column" / "function". pub description: String, /// The logical type. pub item_type: ItemType, /// Text to insert into the textarea when accepted. pub insert_value: String, + /// Priority tier used as a tiebreaker when fuzzy scores are equal. + /// Higher = shown first. Public tables=3, non-public tables=2, columns=1, functions=0. + pub priority: u32, } diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 3529d05..712a7ec 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -267,18 +267,20 @@ impl SchemaCache { result } - /// Return ALL (table, column) pairs — used by the fuzzy completer. - pub fn get_all_columns(&self) -> Vec<(String, String)> { + /// Return ALL (schema, table, column) triples — used by the fuzzy completer. + pub fn get_all_columns(&self) -> Vec<(String, String, String)> { let tables = self.tables.read().unwrap(); - let mut result: Vec<(String, String)> = tables + let mut result: Vec<(String, String, String)> = tables .values() .flat_map(|t| { + let schema = t.schema_name.clone(); + let table = t.table_name.clone(); t.columns .iter() - .map(|c| (t.table_name.clone(), c.name.clone())) + .map(move |c| (schema.clone(), table.clone(), c.name.clone())) }) .collect(); - result.sort_by(|a, b| a.1.cmp(&b.1)); + result.sort_by(|a, b| a.2.cmp(&b.2)); result } From 46f4f9396072937778e535e5a03d5d00a3ccb7da Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 19:37:04 +0100 Subject: [PATCH 051/147] Unify tab/fuzzy completion with shared candidate logic and fix priority bugs - Add candidates.rs: shared Candidate struct and collect_candidates() used by both SqlCompleter and FuzzyCompleter with consistent priority tiers (column-in-query 5000, public table 4500, schema 4400, other table 4000, column-other 3000, function 1000, system 0) - Tab completion: columns filtered to query-referenced tables; non-public tables filtered to referenced schemas when partial is empty - Fuzzy search: now passes current SQL tables for context-aware scoring, matching tab-completion priority behaviour - Fix: in_query now checked before is_system for column priority, so columns from information_schema tables referenced in SQL surface at top of results - Fix: schema_cache do_refresh uses entry().or_insert_with() so tables found only in information_schema.columns (not .tables) are still cached - Add 26 unit tests covering filtering, priority ordering, and prefix matching Co-Authored-By: Claude Sonnet 4.6 --- src/completion/candidates.rs | 516 ++++++++++++++++++++++++++++++ src/completion/fuzzy_completer.rs | 101 +++--- src/completion/mod.rs | 465 ++++++++++++++++++--------- src/completion/schema_cache.rs | 31 +- src/tui/mod.rs | 27 +- 5 files changed, 916 insertions(+), 224 deletions(-) create mode 100644 src/completion/candidates.rs diff --git a/src/completion/candidates.rs b/src/completion/candidates.rs new file mode 100644 index 0000000..b5553d3 --- /dev/null +++ b/src/completion/candidates.rs @@ -0,0 +1,516 @@ +/// Shared completion candidate collection used by both tab-completion and fuzzy search. +/// +/// Both completers call `collect_candidates()` to get a unified, consistently-scored +/// list of schema items. Tab completion then applies additional context filters +/// (hide columns from unrelated tables, hide non-public tables from unreferenced schemas). +/// Fuzzy search uses the raw candidates as-is. +use std::sync::Arc; + +use super::schema_cache::SchemaCache; +use super::usage_tracker::{ItemType, UsageTracker}; + +// ── Priority tiers ──────────────────────────────────────────────────────────── +// Higher value → shown first. +// +// Ordering (highest first): +// 5000 Column from a table referenced in the current SQL +// 4500 Public table +// 4400 Schema completion (e.g. "information_schema.") +// 4000 Non-public table +// 3000 Column from a table NOT in the current SQL +// 1000 Function +// 0 System-schema items (information_schema / pg_catalog) +// +// Usage bonus adds up to 990 (99 uses × 10 pts) within each tier. + +/// Column belongs to a table referenced in the current SQL statement. +pub const PRIORITY_COLUMN_IN_QUERY: u32 = 5000; +/// Public-schema table (shown as bare name). +pub const PRIORITY_PUBLIC_TABLE: u32 = 4500; +/// Schema completion (e.g. "information_schema.") — just below public tables. +pub const PRIORITY_SCHEMA: u32 = 4400; +/// Non-public table. +pub const PRIORITY_NONPUBLIC_TABLE: u32 = 4000; +/// Column from a table *not* referenced in the current statement. +pub const PRIORITY_COLUMN_OTHER: u32 = 3000; +/// SQL function. +pub const PRIORITY_FUNCTION: u32 = 1000; +/// Items from system schemas (information_schema, pg_catalog). +/// Must be < PRIORITY_FUNCTION even with max usage bonus (0 + 99*10 = 990 < 1000). +pub const PRIORITY_SYSTEM: u32 = 0; + +const MAX_USAGE: u32 = 99; +const USAGE_MULTIPLIER: u32 = 10; + +// ── Candidate ───────────────────────────────────────────────────────────────── + +/// A single completion candidate produced by `collect_candidates`. +#[derive(Debug, Clone)] +pub struct Candidate { + /// Text shown in the popup and used as the default insert value. + pub display: String, + /// Text actually inserted when the candidate is accepted. + /// Differs from `display` for functions: display = "fn()", insert = "fn(". + pub insert: String, + /// Short type label: "table", "column", "function", or "schema". + pub description: &'static str, + /// Logical type — used for selectivity filtering. + pub item_type: ItemType, + /// Schema name; empty string for public-schema items and functions. + pub schema: String, + /// Table that owns this column (Column items only). + pub table_name: Option, + /// Alternative names tried for prefix matching in addition to `display`. + /// + /// Examples: + /// - non-public table `info.engine_qh` → alts = `["engine_qh"]` + /// - public column `test.val` → alts = `["val"]` + /// - non-public col `info.engine_qh.t` → alts = `["engine_qh.t", "t"]` + pub alts: Vec, + /// Priority score; higher items appear first. Includes usage bonus. + pub priority: u32, +} + +impl Candidate { + /// Returns `true` if `partial` is a prefix of `display` or any alternative name. + /// Empty partial always matches (shows all candidates). + pub fn prefix_matches(&self, partial: &str) -> bool { + if partial.is_empty() { + return true; + } + let p = partial.to_lowercase(); + if self.display.to_lowercase().starts_with(&p) { + return true; + } + self.alts.iter().any(|a| a.to_lowercase().starts_with(&p)) + } +} + +// ── Query-context helpers ───────────────────────────────────────────────────── + +/// Returns `true` if the given table (identified by `table_name` + `schema`) +/// is referenced anywhere in `query_tables` (the list of tables extracted from +/// the current SQL statement by `ContextAnalyzer::extract_tables`). +/// +/// Matches bare name, schema-qualified name, and cross-qualified variations. +pub fn table_in_query(table_name: &str, schema: &str, query_tables: &[String]) -> bool { + if query_tables.is_empty() { + return false; + } + let t = table_name.to_lowercase(); + let s = schema.to_lowercase(); + let qualified = format!("{}.{}", s, t); + + query_tables.iter().any(|qt| { + let q = qt.to_lowercase(); + q == t // bare match ("users" == "users") + || q == qualified // full match ("public.users" == "public.users") + || q.ends_with(&format!(".{}", t)) // query "public.users", cache bare "users" + || qualified == q // same as above but other way + }) +} + +/// Returns `true` if the given schema name is referenced in `query_tables`. +/// +/// Always returns `true` for the public schema (empty string or "public") because +/// public tables are always visible in SQL without schema qualification. +pub fn schema_in_query(schema: &str, query_tables: &[String]) -> bool { + if schema == "public" || schema.is_empty() { + return true; + } + if query_tables.is_empty() { + return false; + } + let s = schema.to_lowercase(); + query_tables.iter().any(|qt| { + let q = qt.to_lowercase(); + q.starts_with(&format!("{}.", s)) || q == s + }) +} + +// ── Candidate collection ────────────────────────────────────────────────────── + +/// Collect all completion candidates from the schema cache with priority scores. +/// +/// Pass `tables_in_query` (from `ContextAnalyzer::extract_tables`) to get +/// context-aware column priority. Fuzzy search passes `&[]` (no context). +pub fn collect_candidates( + cache: &SchemaCache, + usage_tracker: Arc, + tables_in_query: &[String], +) -> Vec { + let mut items = Vec::new(); + + // ── Tables ─────────────────────────────────────────────────────────────── + let mut seen_schemas: std::collections::HashSet = std::collections::HashSet::new(); + + for (schema, table) in cache.get_all_tables() { + let is_public = schema == "public" || schema.is_empty(); + let is_system = is_system_schema(&schema); + + let (display, insert) = if is_public { + (table.clone(), table.clone()) + } else { + let q = format!("{}.{}", schema, table); + (q.clone(), q) + }; + + // Non-public tables expose the bare table name as an alternative so + // the user can type e.g. "engine_q" and still see + // "information_schema.engine_query_history". + let alts: Vec = if is_public { vec![] } else { vec![table.clone()] }; + + let usage_count = usage_tracker.get_count(ItemType::Table, &table); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + + let base = if is_system { + PRIORITY_SYSTEM + } else if is_public { + PRIORITY_PUBLIC_TABLE + } else { + PRIORITY_NONPUBLIC_TABLE + }; + + items.push(Candidate { + display, + insert, + description: "table", + item_type: ItemType::Table, + schema: schema.clone(), + table_name: None, + alts, + priority: base.saturating_add(usage_bonus), + }); + + if !is_public && !schema.is_empty() { + seen_schemas.insert(schema); + } + } + + // ── Schemas ────────────────────────────────────────────────────────────── + // Emit "schema." completions so the user can drill into a schema. + let mut schema_list: Vec = seen_schemas.into_iter().collect(); + schema_list.sort(); + + for schema in schema_list { + let schema_with_dot = format!("{}.", schema); + let usage_count = usage_tracker.get_count(ItemType::Table, &schema); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + let base = if is_system_schema(&schema) { PRIORITY_SYSTEM } else { PRIORITY_SCHEMA }; + + items.push(Candidate { + display: schema_with_dot.clone(), + insert: schema_with_dot, + description: "schema", + item_type: ItemType::Schema, + schema: schema.clone(), + table_name: None, + alts: vec![], + priority: base.saturating_add(usage_bonus), + }); + } + + // ── Columns ────────────────────────────────────────────────────────────── + for (schema, table, column) in cache.get_all_columns() { + let is_public = schema == "public" || schema.is_empty(); + let is_system = is_system_schema(&schema); + + // Always emit qualified names: "table.col" or "schema.table.col" + let display = if is_public { + format!("{}.{}", table, column) + } else { + format!("{}.{}.{}", schema, table, column) + }; + + // Alternative prefix matches: + // public: "col" (bare column name) + // non-public: "table.col", "col" (also allows "table" prefix to trigger) + let alts: Vec = if is_public { + vec![column.clone()] + } else { + vec![format!("{}.{}", table, column), column.clone()] + }; + + let in_query = + !tables_in_query.is_empty() && table_in_query(&table, &schema, tables_in_query); + + // in_query always takes precedence: if the user explicitly referenced this + // table in the current SQL, its columns should be shown at top priority + // regardless of whether the schema is a system schema. + let base = if in_query { + PRIORITY_COLUMN_IN_QUERY + } else if is_system { + PRIORITY_SYSTEM + } else { + PRIORITY_COLUMN_OTHER + }; + + items.push(Candidate { + display: display.clone(), + insert: display, + description: "column", + item_type: ItemType::Column, + schema: schema.clone(), + table_name: Some(table), + alts, + priority: base, + }); + } + + // ── Functions ───────────────────────────────────────────────────────────── + for function in cache.get_all_functions() { + let usage_count = usage_tracker.get_count(ItemType::Function, &function); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + + items.push(Candidate { + display: format!("{}()", function), + insert: format!("{}(", function), + description: "function", + item_type: ItemType::Function, + schema: String::new(), + table_name: None, + // Match without parens in case user types "func_name" without "(" + alts: vec![function.clone()], + priority: PRIORITY_FUNCTION.saturating_add(usage_bonus), + }); + } + + items +} + +fn is_system_schema(schema: &str) -> bool { + schema == "information_schema" || schema == "pg_catalog" +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + // ── table_in_query ──────────────────────────────────────────────────────── + + #[test] + fn test_table_in_query_bare_match() { + let q = &["users".to_string()]; + assert!(table_in_query("users", "public", q)); + assert!(!table_in_query("orders", "public", q)); + } + + #[test] + fn test_table_in_query_qualified_in_sql() { + // SQL has "information_schema.engine_query_history", cache has bare table name + let q = &["information_schema.engine_query_history".to_string()]; + assert!(table_in_query( + "engine_query_history", + "information_schema", + q + )); + assert!(!table_in_query("columns", "information_schema", q)); + } + + #[test] + fn test_table_in_query_cross_qualified() { + // SQL has "public.users", cache table bare "users" + let q1 = &["public.users".to_string()]; + assert!(table_in_query("users", "public", q1)); + + // SQL has bare "users", cache "public.users" + let q2 = &["users".to_string()]; + assert!(table_in_query("users", "public", q2)); + } + + #[test] + fn test_table_in_query_empty_query_tables() { + assert!(!table_in_query("users", "public", &[])); + } + + // ── schema_in_query ─────────────────────────────────────────────────────── + + #[test] + fn test_schema_in_query_public_always_visible() { + assert!(schema_in_query("public", &[])); + assert!(schema_in_query("", &[])); + assert!(schema_in_query("public", &["orders".to_string()])); + } + + #[test] + fn test_schema_in_query_nonpublic_found() { + let q = &["information_schema.columns".to_string()]; + assert!(schema_in_query("information_schema", q)); + } + + #[test] + fn test_schema_in_query_nonpublic_not_found() { + let q = &["orders".to_string()]; + assert!(!schema_in_query("information_schema", q)); + assert!(!schema_in_query("pg_catalog", q)); + } + + #[test] + fn test_schema_in_query_empty_query_tables() { + assert!(!schema_in_query("information_schema", &[])); + } + + // ── Candidate::prefix_matches ───────────────────────────────────────────── + + #[test] + fn test_prefix_matches_empty_always_true() { + let c = make_column_candidate( + "information_schema", + "engine_query_history", + "start_time", + ); + assert!(c.prefix_matches("")); + } + + #[test] + fn test_prefix_matches_via_display() { + let c = make_column_candidate("public", "orders", "order_id"); + // Public column display = "orders.order_id" + assert!(c.prefix_matches("orders.ord")); + assert!(!c.prefix_matches("users.order")); + } + + #[test] + fn test_prefix_matches_via_bare_col() { + let c = make_column_candidate("public", "orders", "order_id"); + // Alt = "order_id" + assert!(c.prefix_matches("order")); + assert!(!c.prefix_matches("zzzz")); + } + + #[test] + fn test_prefix_matches_nonpublic_via_table_col() { + let c = make_column_candidate( + "information_schema", + "engine_query_history", + "start_time", + ); + // display = "information_schema.engine_query_history.start_time" + // alts = ["engine_query_history.start_time", "start_time"] + assert!(c.prefix_matches("information_schema.engine")); + assert!(c.prefix_matches("engine_query")); // via alt "engine_query_history.start_time" + assert!(c.prefix_matches("start")); // via alt "start_time" + assert!(!c.prefix_matches("nonexistent")); + } + + #[test] + fn test_prefix_matches_nonpublic_table() { + let c = make_table_candidate("information_schema", "columns"); + // display = "information_schema.columns" + // alts = ["columns"] + assert!(c.prefix_matches("information_schema")); + assert!(c.prefix_matches("col")); // via alt + assert!(!c.prefix_matches("zzz")); + } + + // ── collect_candidates priorities ──────────────────────────────────────── + + #[test] + fn test_collect_priority_column_in_query_beats_table() { + let cache = Arc::new(super::super::schema_cache::SchemaCache::new(300)); + let usage_tracker = Arc::new(UsageTracker::new(10)); + // Empty cache — just verify priority constants are ordered correctly + assert!(PRIORITY_COLUMN_IN_QUERY > PRIORITY_PUBLIC_TABLE); + assert!(PRIORITY_PUBLIC_TABLE > PRIORITY_SCHEMA); + assert!(PRIORITY_SCHEMA > PRIORITY_NONPUBLIC_TABLE); + assert!(PRIORITY_NONPUBLIC_TABLE > PRIORITY_COLUMN_OTHER); + assert!(PRIORITY_COLUMN_OTHER > PRIORITY_FUNCTION); + assert!(PRIORITY_FUNCTION > PRIORITY_SYSTEM); + } + + /// Columns from a system-schema table that is referenced in the current SQL + /// must get PRIORITY_COLUMN_IN_QUERY, not PRIORITY_SYSTEM. + /// + /// Regression test: previously `is_system` was checked before `in_query`, + /// burying information_schema columns at priority 0 even when the user had + /// written `FROM information_schema.engine_query_history`. + #[test] + fn test_system_schema_column_in_query_gets_high_priority() { + // Create a candidate as collect_candidates would + let tables_in_query = vec!["information_schema.engine_query_history".to_string()]; + let in_query = table_in_query( + "engine_query_history", + "information_schema", + &tables_in_query, + ); + assert!(in_query, "table_in_query should match qualified reference"); + + // The priority base should be COLUMN_IN_QUERY, not SYSTEM + let base = if in_query { + PRIORITY_COLUMN_IN_QUERY + } else if is_system_schema_test("information_schema") { + PRIORITY_SYSTEM + } else { + PRIORITY_COLUMN_OTHER + }; + assert_eq!( + base, PRIORITY_COLUMN_IN_QUERY, + "system-schema column from query table must get PRIORITY_COLUMN_IN_QUERY" + ); + } + + fn is_system_schema_test(s: &str) -> bool { + s == "information_schema" || s == "pg_catalog" + } + + #[test] + fn test_collect_system_schema_priority() { + // usage bonus cannot lift a system item above PRIORITY_FUNCTION + let max_system = PRIORITY_SYSTEM + MAX_USAGE * USAGE_MULTIPLIER; + assert!( + max_system < PRIORITY_FUNCTION, + "max_system={} must be < PRIORITY_FUNCTION={}", + max_system, + PRIORITY_FUNCTION + ); + } + + // ── helper constructors for tests ───────────────────────────────────────── + + fn make_column_candidate(schema: &str, table: &str, column: &str) -> Candidate { + let is_public = schema == "public" || schema.is_empty(); + let display = if is_public { + format!("{}.{}", table, column) + } else { + format!("{}.{}.{}", schema, table, column) + }; + let alts: Vec = if is_public { + vec![column.to_string()] + } else { + vec![format!("{}.{}", table, column), column.to_string()] + }; + Candidate { + display: display.clone(), + insert: display, + description: "column", + item_type: ItemType::Column, + schema: schema.to_string(), + table_name: Some(table.to_string()), + alts, + priority: PRIORITY_COLUMN_IN_QUERY, + } + } + + fn make_table_candidate(schema: &str, table: &str) -> Candidate { + let is_public = schema == "public" || schema.is_empty(); + let display = if is_public { + table.to_string() + } else { + format!("{}.{}", schema, table) + }; + let alts: Vec = if is_public { vec![] } else { vec![table.to_string()] }; + Candidate { + display: display.clone(), + insert: display, + description: "table", + item_type: ItemType::Table, + schema: schema.to_string(), + table_name: None, + alts, + priority: PRIORITY_NONPUBLIC_TABLE, + } + } +} diff --git a/src/completion/fuzzy_completer.rs b/src/completion/fuzzy_completer.rs index c8e38db..ce7b224 100644 --- a/src/completion/fuzzy_completer.rs +++ b/src/completion/fuzzy_completer.rs @@ -1,37 +1,50 @@ /// Fuzzy schema search across all tables, columns, and functions. /// /// Used by the Ctrl+Space overlay in the TUI. Collects all items from the -/// schema cache and applies nucleo fuzzy matching to rank them. +/// schema cache via the shared `collect_candidates()` function and applies +/// nucleo fuzzy matching to rank them. use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, Utf32Str, }; use std::sync::Arc; -use super::{schema_cache::SchemaCache, usage_tracker::ItemType}; +use super::candidates::{self, collect_candidates}; +use super::schema_cache::SchemaCache; +use super::usage_tracker::{ItemType, UsageTracker}; pub struct FuzzyCompleter { cache: Arc, + usage_tracker: Arc, matcher: Matcher, } impl FuzzyCompleter { - pub fn new(cache: Arc) -> Self { + pub fn new(cache: Arc, usage_tracker: Arc) -> Self { Self { cache, + usage_tracker, matcher: Matcher::new(Config::DEFAULT), } } /// Return all schema items that fuzzy-match `query`, ranked by score then priority. + /// + /// `tables_in_query` — tables extracted from the current SQL statement + /// (via `ContextAnalyzer::extract_tables`). Used to give context-aware + /// priority boosts to columns whose tables are already referenced, matching + /// the behaviour of tab completion. Pass `&[]` when no context is available. + /// /// Returns up to `limit` items. - pub fn search(&mut self, query: &str, limit: usize) -> Vec { - let all_items = self.collect_all_items(); + pub fn search(&mut self, query: &str, limit: usize, tables_in_query: &[String]) -> Vec { + let all = collect_candidates(&self.cache, self.usage_tracker.clone(), tables_in_query); if query.is_empty() { - let mut items = all_items; + let mut items: Vec = all.into_iter().map(candidate_to_fuzzy).collect(); items.sort_by(|a, b| { - b.priority.cmp(&a.priority).then(a.label.cmp(&b.label)) + b.priority + .cmp(&a.priority) + .then(a.label.cmp(&b.label)) }); items.truncate(limit); return items; @@ -39,12 +52,15 @@ impl FuzzyCompleter { let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); - let mut scored: Vec<(u32, FuzzyItem)> = all_items + let mut scored: Vec<(u32, FuzzyItem)> = all .into_iter() .filter_map(|item| { - let haystack = Utf32Str::Ascii(item.label.as_bytes()); - let score = pattern.score(haystack, &mut self.matcher); - score.map(|s| (s, item)) + let fi = candidate_to_fuzzy(item); + // Run fuzzy match against the display label + let haystack = Utf32Str::Ascii(fi.label.as_bytes()); + pattern + .score(haystack, &mut self.matcher) + .map(|s| (s, fi)) }) .collect(); @@ -56,58 +72,15 @@ impl FuzzyCompleter { scored.truncate(limit); scored.into_iter().map(|(_, item)| item).collect() } +} - /// Collect all schema items (tables + columns + functions) into a flat list. - fn collect_all_items(&self) -> Vec { - let mut items = Vec::new(); - - // Tables - for (schema, table) in self.cache.get_all_tables() { - let is_public = schema == "public" || schema.is_empty(); - let label = if is_public { - table.clone() - } else { - format!("{}.{}", schema, table) - }; - items.push(FuzzyItem { - insert_value: label.clone(), - label, - description: "table".to_string(), - item_type: ItemType::Table, - // Public tables rank above non-public tables. - priority: if is_public { 3 } else { 2 }, - }); - } - - // Columns — always shown as table.column (or schema.table.column for non-public). - for (schema, table, column) in self.cache.get_all_columns() { - let is_public = schema == "public" || schema.is_empty(); - let label = if is_public { - format!("{}.{}", table, column) - } else { - format!("{}.{}.{}", schema, table, column) - }; - items.push(FuzzyItem { - insert_value: label.clone(), - label, - description: "column".to_string(), - item_type: ItemType::Column, - priority: 1, - }); - } - - // Functions - for function in self.cache.get_all_functions() { - items.push(FuzzyItem { - label: format!("{}()", function), - description: "function".to_string(), - item_type: ItemType::Function, - insert_value: format!("{}(", function), - priority: 0, - }); - } - - items +fn candidate_to_fuzzy(c: candidates::Candidate) -> FuzzyItem { + FuzzyItem { + label: c.display, + description: c.description.to_string(), + item_type: c.item_type, + insert_value: c.insert, + priority: c.priority, } } @@ -116,13 +89,13 @@ impl FuzzyCompleter { pub struct FuzzyItem { /// Displayed text in the result list. pub label: String, - /// Short description matching tab-completion labels: "table" / "column" / "function". + /// Short description matching tab-completion labels: "table" / "column" / "function" / "schema". pub description: String, /// The logical type. pub item_type: ItemType, /// Text to insert into the textarea when accepted. pub insert_value: String, /// Priority tier used as a tiebreaker when fuzzy scores are equal. - /// Higher = shown first. Public tables=3, non-public tables=2, columns=1, functions=0. + /// Higher = shown first. Derived from the shared priority constants. pub priority: u32, } diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 8cb11f1..adc0eea 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -1,3 +1,4 @@ +pub mod candidates; pub mod context_analyzer; pub mod context_detector; pub mod fuzzy_completer; @@ -5,6 +6,7 @@ pub mod priority_scorer; pub mod schema_cache; pub mod usage_tracker; +use candidates::{collect_candidates, schema_in_query, table_in_query}; use context_analyzer::ContextAnalyzer; use context_detector::find_word_start; use priority_scorer::{PriorityScorer, ScoredSuggestion}; @@ -68,22 +70,22 @@ impl SqlCompleter { // Extract context: tables mentioned in current statement let tables_in_line = ContextAnalyzer::extract_tables(line); - // Generate scored suggestions - let mut scored: Vec = Vec::new(); - - // Check if user is typing "schema_name." to see tables from that schema - let (schema_filter, table_prefix) = if let Some(dot_pos) = partial.rfind('.') { + // ── Dot-mode: partial already contains a dot ────────────────────────── + // + // Two sub-cases: + // (a) "table." → user explicitly requests columns of that table + // (b) "schema.tab" → user navigates into a schema to pick a table + // + // We detect (a) by checking whether the part before the dot is a table + // referenced in the current SQL. Otherwise we fall through to (b). + if let Some(dot_pos) = partial.rfind('.') { let schema_part = &partial[..dot_pos]; let table_part = &partial[dot_pos + 1..]; - (Some(schema_part), table_part) - } else { - (None, partial) - }; - - if let Some(schema_name) = schema_filter { - // If the part before the dot is a table referenced in the query, treat it as a - // table name and suggest its columns (e.g. "test." → "test.val"). - let schema_lower = schema_name.to_lowercase(); + + let schema_lower = schema_part.to_lowercase(); + let col_prefix_lower = table_part.to_lowercase(); + + // Check if the part before the dot is a query-referenced table let is_query_table = tables_in_line.iter().any(|t| { let t_lower = t.to_lowercase(); t_lower == schema_lower @@ -91,8 +93,10 @@ impl SqlCompleter { || schema_lower.ends_with(&format!(".{}", t_lower)) }); + let mut scored: Vec = Vec::new(); + if is_query_table { - let col_prefix_lower = table_prefix.to_lowercase(); + // (a) Emit columns of this table whose name starts with the column prefix let column_metadata = self.cache.get_columns_with_table(partial); for (table, column) in column_metadata { if let Some(tbl) = table { @@ -115,11 +119,13 @@ impl SqlCompleter { } } } else { - // Part before the dot is a schema name — suggest tables within it. - let tables_in_schema = self.cache.get_tables_in_schema(schema_name, table_prefix); + // (b) Emit tables within the named schema + let tables_in_schema = + self.cache.get_tables_in_schema(schema_part, table_part); for table in tables_in_schema { - let qualified_name = format!("{}.{}", schema_name, table); - let score = self.scorer.score(&table, ItemType::Table, &tables_in_line, None); + let qualified_name = format!("{}.{}", schema_part, table); + let score = + self.scorer.score(&table, ItemType::Table, &tables_in_line, None); scored.push(ScoredSuggestion { name: qualified_name, item_type: ItemType::Table, @@ -127,147 +133,98 @@ impl SqlCompleter { }); } } - } else { - let table_metadata = self.cache.get_tables_with_schema(partial); - let column_metadata = self.cache.get_columns_with_table(partial); - let schemas = self.cache.get_schemas(partial); - - let partial_lower = partial.to_lowercase(); - - let partial_matches_schema = !partial.contains('.') && - schemas.iter().any(|s| s.to_lowercase().starts_with(&partial_lower)); - - for schema in &schemas { - let schema_with_dot = format!("{}.", schema); - if schema_with_dot.to_lowercase().starts_with(&partial_lower) { - let base_score = 4000u32; - let usage_count = self.usage_tracker.get_count(ItemType::Table, schema); - let usage_bonus = usage_count.min(99) * 10; - let score = base_score + usage_bonus; - - scored.push(ScoredSuggestion { - name: schema_with_dot, - item_type: ItemType::Schema, - score, - }); - } - } - - for (schema, table) in table_metadata { - let short_name = table.clone(); - let qualified_name = format!("{}.{}", schema, table); - let short_lower = short_name.to_lowercase(); - let qualified_lower = qualified_name.to_lowercase(); - - let base_score = self.scorer.score(&short_name, ItemType::Table, &tables_in_line, None); - - if schema == "public" || schema.is_empty() { - // Public tables: show as bare name; rank 500 above non-public. - if short_lower.starts_with(&partial_lower) { - scored.push(ScoredSuggestion { - name: short_name.clone(), - item_type: ItemType::Table, - score: base_score + 500, - }); - } - } else { - // Non-public tables: always show as schema.table. - // Match when typing just the table name prefix ("ord") OR - // the full qualified prefix ("myschema.ord"). - if (short_lower.starts_with(&partial_lower) - || qualified_lower.starts_with(&partial_lower)) - && !partial_matches_schema - { - scored.push(ScoredSuggestion { - name: qualified_name, - item_type: ItemType::Table, - score: base_score, - }); + scored.sort_by(|a, b| b.score.cmp(&a.score).then(a.name.cmp(&b.name))); + let mut seen = std::collections::HashSet::new(); + let items: Vec = scored + .into_iter() + .filter(|s| seen.insert(s.name.clone())) + .map(|s| CompletionItem { + value: s.name, + description: match s.item_type { + ItemType::Column => "column", + ItemType::Table => "table", + ItemType::Function => "function", + ItemType::Schema => "schema", } - } - } + .to_string(), + item_type: s.item_type, + }) + .collect(); - for (table, column) in column_metadata { - // Only emit qualified "table.column" candidates for tables that appear in - // the current SQL text. Columns from unmentioned tables are skipped. - if let Some(tbl) = table { - let tbl_lower = tbl.to_lowercase(); - let table_in_query = tables_in_line.iter().any(|t| { - let t_lower = t.to_lowercase(); - t_lower == tbl_lower - || t_lower.ends_with(&format!(".{}", tbl_lower)) - || tbl_lower.ends_with(&format!(".{}", t_lower)) - }); - if !table_in_query { - continue; - } + return (word_start, items); + } - let qualified_name = format!("{}.{}", tbl, column); - let qualified_lower = qualified_name.to_lowercase(); - let col_lower = column.to_lowercase(); - - // Match when the user types just the column name prefix ("acco") OR - // the qualified prefix ("orders.acc"). - if col_lower.starts_with(&partial_lower) || qualified_lower.starts_with(&partial_lower) { - let score = self.scorer.score( - &qualified_name, - ItemType::Column, - &tables_in_line, - Some(&tbl), - ); - scored.push(ScoredSuggestion { - name: qualified_name, - item_type: ItemType::Column, - score, - }); - } + // ── No-dot mode: unified candidate collection + tab selectivity ──────── + // + // Collect all schema items with context-aware priority, then apply + // tab-completion's extra selectivity rules: + // • Columns — only if their table appears in the current SQL. + // • Non-public tables — only if their schema appears in the current + // SQL *or* the user is explicitly typing the schema name as a prefix. + // • Schema completions (e.g. "information_schema.") — only when the + // partial does not already contain a dot (suppressed above). + + let all = collect_candidates(&self.cache, self.usage_tracker.clone(), &tables_in_line); + let partial_lower = partial.to_lowercase(); + + let mut filtered: Vec<&candidates::Candidate> = all + .iter() + // ── prefix match ────────────────────────────────────────────────── + .filter(|c| c.prefix_matches(partial)) + // ── tab-specific selectivity ────────────────────────────────────── + .filter(|c| match c.item_type { + ItemType::Column => { + // Show only columns whose table appears in the current SQL. + let table = c.table_name.as_deref().unwrap_or(""); + table_in_query(table, &c.schema, &tables_in_line) } - } - - let functions = self.cache.get_functions(partial); - for function in functions { - if function.to_lowercase().starts_with(&partial_lower) { - let base_score = 1000u32; - let usage_count = self.usage_tracker.get_count(ItemType::Function, &function); - let usage_bonus = usage_count.min(99) * 10; - let score = base_score + usage_bonus; - let function_with_paren = format!("{}(", function); - - scored.push(ScoredSuggestion { - name: function_with_paren, - item_type: ItemType::Function, - score, - }); + ItemType::Table => { + // Public tables: always visible. + // Non-public tables: + // • non-empty partial → always show when prefix matches + // (user is deliberately typing toward this table) + // • empty partial → only show if the schema is already + // referenced in the current SQL (avoids flooding the + // list with dozens of system-schema tables on Tab alone) + c.schema == "public" + || c.schema.is_empty() + || !partial.is_empty() + || schema_in_query(&c.schema, &tables_in_line) } - } - } + ItemType::Schema => { + // Show schema completions only when: + // (a) no dot has been typed yet (dot-mode handles the rest), AND + // (b) the user is explicitly typing the schema name prefix, OR + // the schema is already referenced somewhere in the SQL. + !partial_lower.contains('.') + && (!partial.is_empty() + || schema_in_query(&c.schema, &tables_in_line)) + } + ItemType::Function => true, + }) + .collect(); - // Sort by score descending - scored.sort_by(|a, b| b.score.cmp(&a.score)); + // Sort by priority descending, then alphabetically as tiebreaker + filtered.sort_by(|a, b| { + b.priority + .cmp(&a.priority) + .then(a.display.cmp(&b.display)) + }); - // Deduplicate (keep highest-scored occurrence), then convert to CompletionItem + // Deduplicate (keep highest-priority occurrence) let mut seen = std::collections::HashSet::new(); - let candidates: Vec = scored + let items: Vec = filtered .into_iter() - .filter(|s| seen.insert(s.name.clone())) - .map(|s| { - let description = match s.item_type { - ItemType::Table => "table", - ItemType::Column => "column", - ItemType::Function => "function", - ItemType::Schema => "schema", - } - .to_string(); - CompletionItem { - value: s.name, - description, - item_type: s.item_type, - } + .filter(|c| seen.insert(c.display.clone())) + .map(|c| CompletionItem { + value: c.insert.clone(), + description: c.description.to_string(), + item_type: c.item_type, }) .collect(); - (word_start, candidates) + (word_start, items) } } @@ -296,4 +253,218 @@ mod tests { // Should not return keywords (only tables and columns from schema cache) assert!(!candidates.iter().any(|c| c.value == "SELECT")); } + + // ── Tab selectivity: columns ────────────────────────────────────────────── + + /// Columns from tables NOT in the current SQL must be hidden. + #[test] + fn test_tab_hides_columns_from_unrelated_tables() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![ + TableMetadata { + schema_name: "public".to_string(), + table_name: "orders".to_string(), + columns: vec![ColumnMetadata { + name: "order_id".to_string(), + data_type: "integer".to_string(), + }], + }, + TableMetadata { + schema_name: "public".to_string(), + table_name: "users".to_string(), + columns: vec![ColumnMetadata { + name: "user_id".to_string(), + data_type: "integer".to_string(), + }], + }, + ]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // SQL references "orders" but NOT "users" + let (_, cands) = completer.complete_at("SELECT ord FROM orders", 10); + + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("order_id")), + "should suggest orders.order_id" + ); + assert!( + !values.iter().any(|v| v.contains("user_id")), + "should NOT suggest user_id (users table not in SQL)" + ); + } + + /// Columns from the referenced table should be suggested. + #[test] + fn test_tab_shows_columns_for_referenced_table() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "public".to_string(), + table_name: "users".to_string(), + columns: vec![ + ColumnMetadata { + name: "user_id".to_string(), + data_type: "integer".to_string(), + }, + ColumnMetadata { + name: "username".to_string(), + data_type: "text".to_string(), + }, + ], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // Empty partial after the table reference + let (_, cands) = completer.complete_at("SELECT FROM users", 7); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!(values.iter().any(|v| v.contains("user_id"))); + assert!(values.iter().any(|v| v.contains("username"))); + } + + // ── Tab selectivity: non-public tables ──────────────────────────────────── + + /// Non-public tables must be hidden when their schema is not in the SQL. + #[test] + fn test_tab_hides_nonpublic_table_when_schema_not_in_sql() { + use schema_cache::TableMetadata; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![ + TableMetadata { + schema_name: "public".to_string(), + table_name: "orders".to_string(), + columns: vec![], + }, + TableMetadata { + schema_name: "analytics".to_string(), + table_name: "events".to_string(), + columns: vec![], + }, + ]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // SQL does not mention "analytics" schema + let (_, cands) = completer.complete_at("SELECT FROM orders", 7); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + !values.iter().any(|v| v.contains("analytics")), + "should NOT show analytics.events when analytics is not in SQL" + ); + assert!( + values.iter().any(|v| *v == "orders"), + "should still show public table" + ); + } + + /// Non-public tables must be visible when their schema appears in the SQL. + #[test] + fn test_tab_shows_nonpublic_table_when_schema_in_sql() { + use schema_cache::TableMetadata; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "analytics".to_string(), + table_name: "events".to_string(), + columns: vec![], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // SQL already references analytics.events + let (_, cands) = + completer.complete_at("SELECT FROM analytics.events", 7); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("analytics.events")), + "should show analytics.events when analytics is referenced" + ); + } + + /// Non-public table must show when the user explicitly types the schema prefix. + #[test] + fn test_tab_shows_nonpublic_table_when_typing_schema_prefix() { + use schema_cache::TableMetadata; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "analytics".to_string(), + table_name: "events".to_string(), + columns: vec![], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // User typed "analytics" (the schema name) in the no-schema-in-sql case + let (_, cands) = completer.complete_at("SELECT analytics FROM", 16); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("analytics")), + "should show analytics.events when user types analytics prefix" + ); + } + + // ── Dot-mode ────────────────────────────────────────────────────────────── + + /// "table." should produce columns from that table (when table is in SQL). + #[test] + fn test_dot_mode_shows_columns_when_table_in_sql() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "public".to_string(), + table_name: "test".to_string(), + columns: vec![ + ColumnMetadata { + name: "val".to_string(), + data_type: "integer".to_string(), + }, + ColumnMetadata { + name: "name".to_string(), + data_type: "text".to_string(), + }, + ], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + let (_, cands) = completer.complete_at("SELECT test. FROM test", 12); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!(values.iter().any(|v| v.contains("val"))); + assert!(values.iter().any(|v| v.contains("name"))); + } + + /// Column filter should also work when the table is schema-qualified in SQL. + #[test] + fn test_tab_columns_schema_qualified_in_sql() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "information_schema".to_string(), + table_name: "engine_query_history".to_string(), + columns: vec![ColumnMetadata { + name: "start_time".to_string(), + data_type: "timestamptz".to_string(), + }], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // Table referenced as "information_schema.engine_query_history" + let (_, cands) = completer + .complete_at("SELECT FROM information_schema.engine_query_history", 7); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("start_time")), + "should suggest start_time for information_schema.engine_query_history" + ); + } } diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 712a7ec..6586e0b 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -396,6 +396,18 @@ impl SchemaCache { result } + /// Test-only helper: directly populate the cache with the given tables. + #[cfg(test)] + pub fn inject_test_tables(&self, tables: Vec) { + let mut map = std::collections::HashMap::new(); + for t in tables { + let key = format!("{}.{}", t.schema_name, t.table_name); + map.insert(key, t); + } + *self.tables.write().unwrap() = map; + self.complete_refresh(); + } + async fn do_refresh(&self, context: &mut Context) -> Result<(), Box> { // Query tables (including system schemas - they'll be deprioritized by the scorer) let tables_query = "SELECT table_schema, table_name \ @@ -447,18 +459,23 @@ impl SchemaCache { } } - // Parse columns and add to tables + // Parse columns and add to tables. + // Use the entry API so that tables appearing only in information_schema.columns + // (but not in information_schema.tables) still get cache entries with their columns. match columns_result { Ok(columns_output) => { if let Some(column_list) = Self::parse_columns(&columns_output) { for (schema, table, column, data_type) in column_list { let key = format!("{}.{}", schema, table); - if let Some(table_meta) = new_tables.get_mut(&key) { - table_meta.columns.push(ColumnMetadata { - name: column, - data_type, - }); - } + let table_meta = new_tables.entry(key).or_insert_with(|| TableMetadata { + schema_name: schema.clone(), + table_name: table.clone(), + columns: Vec::new(), + }); + table_meta.columns.push(ColumnMetadata { + name: column, + data_type, + }); } } else { eprintln!("Warning: Failed to parse columns from schema query"); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 389f1ef..c901447 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -30,6 +30,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tui_textarea::{CursorMove, Input, TextArea}; +use crate::completion::context_analyzer::ContextAnalyzer; use crate::completion::fuzzy_completer::FuzzyCompleter; use crate::completion::schema_cache::SchemaCache; use crate::completion::usage_tracker::UsageTracker; @@ -131,8 +132,11 @@ impl TuiApp { .usage_tracker .clone() .unwrap_or_else(|| Arc::new(UsageTracker::new(10))); - let completer = - SqlCompleter::new(schema_cache.clone(), usage_tracker, !context.args.no_completion); + let completer = SqlCompleter::new( + schema_cache.clone(), + usage_tracker.clone(), + !context.args.no_completion, + ); let history_path = crate::utils::history_path().unwrap_or_default(); let mut history = History::new(history_path); @@ -142,7 +146,7 @@ impl TuiApp { let highlighter = SqlHighlighter::new(!context.args.no_completion).unwrap_or_else(|_| { SqlHighlighter::new(false).unwrap() }); - let fuzzy_completer = FuzzyCompleter::new(schema_cache.clone()); + let fuzzy_completer = FuzzyCompleter::new(schema_cache.clone(), usage_tracker); Self { context, @@ -656,9 +660,18 @@ impl TuiApp { // ── Fuzzy search ───────────────────────────────────────────────────────── + /// Extract the tables referenced in the current textarea content so fuzzy + /// search can apply the same context-aware priority as tab completion. + fn current_sql_tables(&self) -> Vec { + let lines = self.textarea.lines(); + let sql = lines.join("\n"); + ContextAnalyzer::extract_tables(&sql) + } + fn open_fuzzy_search(&mut self) { + let tables = self.current_sql_tables(); let mut state = FuzzyState::new(); - state.items = self.fuzzy_completer.search("", 100); + state.items = self.fuzzy_completer.search("", 100, &tables); self.fuzzy_state = Some(state); } @@ -703,7 +716,8 @@ impl TuiApp { if let Some(fs) = &mut self.fuzzy_state { fs.pop_char(); let q = fs.query.clone(); - let items = self.fuzzy_completer.search(&q, 100); + let tables = self.current_sql_tables(); + let items = self.fuzzy_completer.search(&q, 100, &tables); self.fuzzy_state.as_mut().unwrap().items = items; } } @@ -713,7 +727,8 @@ impl TuiApp { if let Some(fs) = &mut self.fuzzy_state { fs.push_char(c); let q = fs.query.clone(); - let items = self.fuzzy_completer.search(&q, 100); + let tables = self.current_sql_tables(); + let items = self.fuzzy_completer.search(&q, 100, &tables); self.fuzzy_state.as_mut().unwrap().items = items; } } From 9818fa37d0821f4f3653db892e448e134ac337bc Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 19:58:04 +0100 Subject: [PATCH 052/147] Improve TUI syntax highlighting and output pane styling - Fix highlight colors to match pre-rewrite ANSI palette (LightBlue/ LightCyan/LightYellow/LightMagenta instead of dim variants) - Apply syntax highlighting to echoed SQL in output pane instead of plain yellow; handles multi-line queries by distributing spans per line - Disable current-line underline in textarea; replace with a subtle background color (Indexed 234) that only appears on multi-line input Co-Authored-By: Claude Sonnet 4.6 --- src/highlight.rs | 15 ++++++----- src/tui/mod.rs | 57 ++++++++++++++++++++++++++++++++++-------- src/tui/output_pane.rs | 42 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/highlight.rs b/src/highlight.rs index 7cf8777..02593e7 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -28,23 +28,22 @@ impl ColorScheme { } } -/// Ratatui styles for TUI syntax highlighting +/// Ratatui styles for TUI syntax highlighting — mirrors the ANSI escape codes used in +/// headless mode so the TUI and terminal output look identical. fn keyword_style() -> Style { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) + Style::default().fg(Color::LightBlue) // \x1b[94m Bright Blue } fn function_style() -> Style { - Style::default().fg(Color::Blue) + Style::default().fg(Color::LightCyan) // \x1b[96m Bright Cyan } fn string_style() -> Style { - Style::default().fg(Color::Yellow) + Style::default().fg(Color::LightYellow) // \x1b[93m Bright Yellow } fn number_style() -> Style { - Style::default().fg(Color::Magenta) + Style::default().fg(Color::LightMagenta) // \x1b[95m Bright Magenta } fn comment_style() -> Style { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::DarkGray) // \x1b[90m Dark Gray } // SQL Keywords pattern diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c901447..43ac387 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -176,7 +176,12 @@ impl TuiApp { } fn make_textarea() -> TextArea<'static> { - TextArea::default() + let mut ta = TextArea::default(); + // Disable the current-line underline — terminal underlines inherit the + // foreground colour, which would make syntax-highlighted lines look + // inconsistent (each token segment would draw its underline in a + // different colour). + ta } // ── Public entry point ─────────────────────────────────────────────────── @@ -912,7 +917,7 @@ impl TuiApp { n_runs, if n_runs == 1 { "" } else { "s" } )); - self.output.push_prompt(format!("❯ {}", query_text.trim())); + self.push_sql_echo(query_text.trim()); let mut times_ms: Vec = Vec::with_capacity(n_runs); @@ -1086,15 +1091,35 @@ impl TuiApp { // ── Query execution ────────────────────────────────────────────────────── - async fn execute_queries(&mut self, original_text: String, queries: Vec) { - // Echo query to output pane with ❯ prompt, one visual line per SQL line - let mut lines = original_text.trim().lines(); - if let Some(first) = lines.next() { - self.output.push_prompt(format!("❯ {}", first)); - for line in lines { - self.output.push_prompt(format!(" {}", line)); - } + /// Echo SQL text to the output pane with the same syntax highlighting as the input pane. + /// The first line gets the `❯ ` prefix (green+bold); continuation lines get ` `. + fn push_sql_echo(&mut self, sql: &str) { + let all_spans = self.highlighter.highlight_to_spans(sql); + let mut byte_offset = 0usize; + let mut first_line = true; + for line_text in sql.lines() { + let line_start = byte_offset; + let line_end = byte_offset + line_text.len(); + // Collect spans for this line, clipped and re-offset to line-local coords + let line_spans: Vec<(std::ops::Range, ratatui::style::Style)> = all_spans + .iter() + .filter(|(r, _)| r.start < line_end && r.end > line_start) + .map(|(r, style)| { + let s = r.start.saturating_sub(line_start); + let e = (r.end - line_start).min(line_text.len()); + (s..e, *style) + }) + .collect(); + let prefix = if first_line { "❯ " } else { " " }; + self.output.push_prompt_highlighted(prefix, line_text, &line_spans); + first_line = false; + byte_offset += line_text.len() + 1; // +1 for '\n' } + } + + async fn execute_queries(&mut self, original_text: String, queries: Vec) { + // Echo query to output pane with syntax highlighting + self.push_sql_echo(original_text.trim()); let (tx, rx) = mpsc::unbounded_channel::(); let cancel_token = CancellationToken::new(); @@ -1133,11 +1158,12 @@ impl TuiApp { fn set_textarea_content(&mut self, content: &str) { let lines: Vec = content.lines().map(|l| l.to_string()).collect(); - self.textarea = TextArea::new(if lines.is_empty() { + let mut ta = TextArea::new(if lines.is_empty() { vec![String::new()] } else { lines }); + self.textarea = ta; // Move cursor to end of content self.textarea.move_cursor(CursorMove::Bottom); self.textarea.move_cursor(CursorMove::End); @@ -1148,6 +1174,15 @@ impl TuiApp { fn render(&mut self, f: &mut ratatui::Frame) { let area = f.area(); + // Only highlight the current line when there are multiple lines — on a + // single-line textarea the highlight adds noise with no benefit. + let cursor_line_bg = if self.textarea.lines().len() > 1 { + ratatui::style::Style::default().bg(ratatui::style::Color::Indexed(234)) + } else { + ratatui::style::Style::default() + }; + self.textarea.set_cursor_line_style(cursor_line_bg); + let input_height = if self.is_running { // Running pane: spinner row + hint row + top/bottom borders = 4 4u16 diff --git a/src/tui/output_pane.rs b/src/tui/output_pane.rs index 1807b71..63ffd2d 100644 --- a/src/tui/output_pane.rs +++ b/src/tui/output_pane.rs @@ -6,6 +6,7 @@ use ratatui::{ text::{Line, Span}, widgets::{Paragraph, Widget}, }; +use std::ops::Range; // ── OutputLine ──────────────────────────────────────────────────────────────── @@ -68,6 +69,47 @@ impl OutputPane { self.scroll_to_bottom(); } + /// Push the echoed prompt line with syntax highlighting. + /// + /// `prefix` is `"❯ "` for the first line or `" "` for continuation lines. + /// `sql_line` is the raw text of one SQL line. + /// `spans` are pre-computed highlight spans whose byte offsets are relative to `sql_line`. + pub fn push_prompt_highlighted( + &mut self, + prefix: &str, + sql_line: &str, + spans: &[(Range, Style)], + ) { + let prefix_span: Span<'static> = if prefix == "❯ " { + Span::styled( + "❯ ".to_string(), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(prefix.to_string()) + }; + + let mut parts: Vec> = vec![prefix_span]; + let mut last = 0usize; + for (range, style) in spans { + let start = range.start.min(sql_line.len()); + let end = range.end.min(sql_line.len()); + if start > last { + parts.push(Span::raw(sql_line[last..start].to_string())); + } + if start < end { + parts.push(Span::styled(sql_line[start..end].to_string(), *style)); + } + last = end; + } + if last < sql_line.len() { + parts.push(Span::raw(sql_line[last..].to_string())); + } + + self.lines.push(OutputLine::from_line(Line::from(parts))); + self.scroll_to_bottom(); + } + /// Push a timing / scan-statistics line — dark gray. pub fn push_stat(&mut self, text: &str) { for line in text.split('\n') { From 329f5781fc4d3ad530bf5ed71cda8b33a1a9b54d Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:02:44 +0100 Subject: [PATCH 053/147] Fix tab completion ignoring tables on other lines in multi-line queries complete_at() was calling extract_tables() on only the current line, so tables referenced on other lines were invisible to the column filter. Pass the full textarea SQL as a separate argument for context extraction while keeping the current line for word boundary detection. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 30 ++++++++++++++++++------------ src/tui/mod.rs | 3 ++- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index adc0eea..3987f83 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -56,9 +56,15 @@ impl SqlCompleter { } /// Return a list of completion candidates for the given input line and cursor position. + /// + /// `line` — the current line up to the cursor (used for word boundary detection). + /// `pos` — cursor byte offset within `line`. + /// `full_sql` — the entire textarea content joined with `\n`; used for table context + /// extraction so tables on other lines are visible to the completer. + /// /// Returns `(word_start, items)` where `word_start` is the byte offset of the /// start of the word being completed. - pub fn complete_at(&self, line: &str, pos: usize) -> (usize, Vec) { + pub fn complete_at(&self, line: &str, pos: usize, full_sql: &str) -> (usize, Vec) { if !self.enabled { return (0, Vec::::new()); } @@ -67,8 +73,8 @@ impl SqlCompleter { let word_start = find_word_start(line, pos); let partial = &line[word_start..pos]; - // Extract context: tables mentioned in current statement - let tables_in_line = ContextAnalyzer::extract_tables(line); + // Extract context from the full SQL so tables on other lines are visible. + let tables_in_line = ContextAnalyzer::extract_tables(full_sql); // ── Dot-mode: partial already contains a dot ────────────────────────── // @@ -238,7 +244,7 @@ mod tests { let usage_tracker = Arc::new(UsageTracker::new(10)); let completer = SqlCompleter::new(cache, usage_tracker, false); - let (_, candidates) = completer.complete_at("SELECT * FROM us", 17); + let (_, candidates) = completer.complete_at("SELECT * FROM us", 17, "SELECT * FROM us"); assert_eq!(candidates.len(), 0); } @@ -248,7 +254,7 @@ mod tests { let usage_tracker = Arc::new(UsageTracker::new(10)); let completer = SqlCompleter::new(cache, usage_tracker, true); - let (_, candidates) = completer.complete_at("SEL", 3); + let (_, candidates) = completer.complete_at("SEL", 3, "SEL"); // Should not return keywords (only tables and columns from schema cache) assert!(!candidates.iter().any(|c| c.value == "SELECT")); @@ -284,7 +290,7 @@ mod tests { let completer = SqlCompleter::new(cache, usage_tracker, true); // SQL references "orders" but NOT "users" - let (_, cands) = completer.complete_at("SELECT ord FROM orders", 10); + let (_, cands) = completer.complete_at("SELECT ord FROM orders", 10, "SELECT ord FROM orders"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!( @@ -321,7 +327,7 @@ mod tests { let completer = SqlCompleter::new(cache, usage_tracker, true); // Empty partial after the table reference - let (_, cands) = completer.complete_at("SELECT FROM users", 7); + let (_, cands) = completer.complete_at("SELECT FROM users", 7, "SELECT FROM users"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!(values.iter().any(|v| v.contains("user_id"))); assert!(values.iter().any(|v| v.contains("username"))); @@ -351,7 +357,7 @@ mod tests { let completer = SqlCompleter::new(cache, usage_tracker, true); // SQL does not mention "analytics" schema - let (_, cands) = completer.complete_at("SELECT FROM orders", 7); + let (_, cands) = completer.complete_at("SELECT FROM orders", 7, "SELECT FROM orders"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!( !values.iter().any(|v| v.contains("analytics")), @@ -379,7 +385,7 @@ mod tests { // SQL already references analytics.events let (_, cands) = - completer.complete_at("SELECT FROM analytics.events", 7); + completer.complete_at("SELECT FROM analytics.events", 7, "SELECT FROM analytics.events"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!( values.iter().any(|v| v.contains("analytics.events")), @@ -402,7 +408,7 @@ mod tests { let completer = SqlCompleter::new(cache, usage_tracker, true); // User typed "analytics" (the schema name) in the no-schema-in-sql case - let (_, cands) = completer.complete_at("SELECT analytics FROM", 16); + let (_, cands) = completer.complete_at("SELECT analytics FROM", 16, "SELECT analytics FROM"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!( values.iter().any(|v| v.contains("analytics")), @@ -435,7 +441,7 @@ mod tests { let usage_tracker = Arc::new(UsageTracker::new(10)); let completer = SqlCompleter::new(cache, usage_tracker, true); - let (_, cands) = completer.complete_at("SELECT test. FROM test", 12); + let (_, cands) = completer.complete_at("SELECT test. FROM test", 12, "SELECT test. FROM test"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!(values.iter().any(|v| v.contains("val"))); assert!(values.iter().any(|v| v.contains("name"))); @@ -460,7 +466,7 @@ mod tests { // Table referenced as "information_schema.engine_query_history" let (_, cands) = completer - .complete_at("SELECT FROM information_schema.engine_query_history", 7); + .complete_at("SELECT FROM information_schema.engine_query_history", 7, "SELECT FROM information_schema.engine_query_history"); let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); assert!( values.iter().any(|v| v.contains("start_time")), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 43ac387..92a1209 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -581,9 +581,10 @@ impl TuiApp { + cursor_col; // current line up to cursor for completion let line_to_cursor = &lines[cursor_row][..cursor_col]; + let full_sql = lines.join("\n"); let (word_start_byte_in_line, items) = - self.completer.complete_at(line_to_cursor, cursor_col); + self.completer.complete_at(line_to_cursor, cursor_col, &full_sql); if items.is_empty() { return; From 810cfc949673541bae05981cc3186977b0bf0dbc Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:14:42 +0100 Subject: [PATCH 054/147] Refresh schema cache after successful DDL statements Detect DDL by observing a zero-column ParsedResult from the query task. Fix query.rs to always emit ParsedResult on success (previously it was gated behind !columns.is_empty(), so DDL never sent one). Add a pending_schema_refresh flag in TuiApp that is set on zero-column success and triggers an async cache refresh when the query channel closes. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 30 +++++++++++++++++------------- src/tui/mod.rs | 23 ++++++++++++++++++++++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/query.rs b/src/query.rs index a69733e..e12f953 100644 --- a/src/query.rs +++ b/src/query.rs @@ -504,22 +504,26 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< out_err!(context, "Error: {}", err.description); } query_failed = true; - } else if !columns.is_empty() { - if !display_emitted { - // All rows fit within limits — emit now - let max_cell = if columns.len() == 1 { context.args.max_cell_length * 5 } else { context.args.max_cell_length }; - if context.is_tui() { - emit_table_tui(context, &columns, &all_rows, terminal_width, max_cell); + } else { + if !columns.is_empty() { + if !display_emitted { + // All rows fit within limits — emit now + let max_cell = if columns.len() == 1 { context.args.max_cell_length * 5 } else { context.args.max_cell_length }; + if context.is_tui() { + emit_table_tui(context, &columns, &all_rows, terminal_width, max_cell); + } else { + let rendered = render_table_output(context, &columns, &all_rows, terminal_width, max_cell); + out!(context, "{}", rendered); + } } else { - let rendered = render_table_output(context, &columns, &all_rows, terminal_width, max_cell); - out!(context, "{}", rendered); + // Partial display was already emitted; show the final total + out_err!(context, "Showing {} of {} rows (press Ctrl+V or \\view to see all).", + format_number(display_rows.len() as u64), + format_number(all_rows.len() as u64)); } - } else { - // Partial display was already emitted; show the final total - out_err!(context, "Showing {} of {} rows (press Ctrl+V or \\view to see all).", - format_number(display_rows.len() as u64), - format_number(all_rows.len() as u64)); } + // Always emit ParsedResult on success so the TUI can detect + // DDL statements (zero columns) and refresh the schema cache. let parsed_result = ParsedResult { columns: columns.clone(), rows: all_rows, diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 92a1209..d9403dc 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -103,6 +103,9 @@ pub struct TuiApp { spinner_tick: u64, running_hint: String, // e.g. "Showing first N rows — collecting remainder..." progress_rows: u64, // rows received so far (streamed from query task) + /// Set when the current query returns a result with zero columns (DDL indicator). + /// Triggers an async schema-cache refresh on successful completion. + pending_schema_refresh: bool, /// Active tab-completion popup session; `None` when the popup is closed. completion_state: Option, @@ -164,6 +167,7 @@ impl TuiApp { spinner_tick: 0, running_hint: String::new(), progress_rows: 0, + pending_schema_refresh: false, completion_state: None, fuzzy_state: None, history_search: None, @@ -287,6 +291,9 @@ impl TuiApp { self.progress_rows = n; } Ok(TuiMsg::ParsedResult(result)) => { + if result.columns.is_empty() { + self.pending_schema_refresh = true; + } self.context.last_result = Some(result); } Ok(TuiMsg::StyledLines(lines)) => { @@ -313,6 +320,20 @@ impl TuiApp { self.running_hint.clear(); self.cancel_token = None; self.query_rx = None; + + // A zero-column result signals likely DDL — refresh the + // schema cache so new/dropped tables appear in completion. + // The flag is only set on success (ParsedResult is only + // emitted when the query completed without errors). + if self.pending_schema_refresh && !self.context.args.no_completion { + self.pending_schema_refresh = false; + let cache = self.schema_cache.clone(); + let mut ctx_clone = self.context.clone(); + tokio::spawn(async move { + let _ = cache.refresh(&mut ctx_clone).await; + }); + } + break; } } @@ -365,7 +386,6 @@ impl TuiApp { (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { self.completion_state = None; self.reset_textarea(); - self.output.push_line("^C"); self.history.reset_navigation(); } @@ -1131,6 +1151,7 @@ impl TuiApp { self.running_hint.clear(); self.progress_rows = 0; self.query_start = Some(Instant::now()); + self.pending_schema_refresh = false; // Build a context clone with the TUI output channel attached let mut ctx = self.context.clone(); From 68ccc923ae0e9bb84387e965b12c7058cce3eecc Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:19:07 +0100 Subject: [PATCH 055/147] Tab completion inserts bare column names instead of qualified names Since tab completion already filters to only show columns from tables in the current query, the table prefix is redundant. Insert just the column name (e.g. "order_id" instead of "orders.order_id"). Fuzzy search keeps fully-qualified names unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/mod.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 3987f83..f75f4f6 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -224,7 +224,19 @@ impl SqlCompleter { .into_iter() .filter(|c| seen.insert(c.display.clone())) .map(|c| CompletionItem { - value: c.insert.clone(), + // For columns: insert only the bare column name (last component + // of the qualified display name). The table is already in the + // query, so there is no need to repeat it on every column reference. + // Other types keep their full insert value. + value: if c.item_type == ItemType::Column { + c.display + .rsplit('.') + .next() + .unwrap_or(&c.insert) + .to_string() + } else { + c.insert.clone() + }, description: c.description.to_string(), item_type: c.item_type, }) From 98e099735683021ec58528c628d6d38ea71cad49 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:24:08 +0100 Subject: [PATCH 056/147] Add Alt+F SQL formatting using sqlformat crate Pressing Alt+F formats the current textarea content with 2-space indentation and uppercased keywords. Adds sqlformat 0.5 dependency. Shows Alt+F in the status bar footer and help text. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 17 +++++++++++++++++ Cargo.toml | 1 + src/tui/mod.rs | 25 ++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 14754f4..58f44a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,6 +806,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sqlformat", "terminal_size 0.3.0", "tokio", "tokio-util", @@ -2376,6 +2377,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f58d7b0190c7f12df7e8be6b79767a0836059159811b869d5ab55721fe14d0" +[[package]] +name = "sqlformat" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0705994df478b895f05b8e290e0d46e53187b26f8d889d37b2a0881234922d94" +dependencies = [ + "unicode_categories", + "winnow", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2893,6 +2904,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 86fa4c2..52b7f85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,4 @@ csvlens = "0.14" devgen-tree-sitter-sql = "0.21.0" tree-sitter = "0.21.0" nucleo-matcher = "0.3.1" +sqlformat = "0.5.0" diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d9403dc..029f3d6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -399,6 +399,11 @@ impl TuiApp { self.open_fuzzy_search(); } + // ── Alt+F: format SQL ───────────────────────────────────────── + (KeyCode::Char('f'), m) if m.contains(KeyModifiers::ALT) => { + self.format_sql(); + } + // ── Tab: trigger / navigate completion ─────────────────────── (KeyCode::Tab, _) => { self.trigger_or_advance_completion(); @@ -1090,6 +1095,7 @@ impl TuiApp { Ctrl+Space - Fuzzy schema search\n\ Tab - Open / navigate completion popup\n\ Shift+Tab - Navigate completion popup backwards\n\ + Alt+F - Format SQL\n\ Ctrl+D - Exit\n\ Ctrl+C - Cancel current input (or running query)\n\ Page Up/Down - Scroll output\n\ @@ -1174,6 +1180,23 @@ impl TuiApp { // ── Textarea helpers ───────────────────────────────────────────────────── + /// Format the current textarea content as SQL (Alt+F). + fn format_sql(&mut self) { + let sql = self.textarea.lines().join("\n"); + if sql.trim().is_empty() { + return; + } + let options = sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(2), + uppercase: Some(true), + ..sqlformat::FormatOptions::default() + }; + let formatted = sqlformat::format(&sql, &sqlformat::QueryParams::None, &options); + if formatted != sql { + self.set_textarea_content(&formatted); + } + } + fn reset_textarea(&mut self) { self.textarea = Self::make_textarea(); } @@ -1418,7 +1441,7 @@ impl TuiApp { } else if self.completion_state.is_some() { " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete ".to_string() + " Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete Alt+F format ".to_string() }; let total = area.width as usize; From 63886dbc8b470a705acba37b5075c44430fac008 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:27:49 +0100 Subject: [PATCH 057/147] Add Ctrl+H hold-to-show help overlay Shows a centered popup with all keybindings and special commands while Ctrl+H is held. Uses REPORT_EVENT_TYPES keyboard enhancement flag to detect key release and dismiss the overlay automatically. Any other key press also dismisses it as a fallback for terminals without release events. Adds Ctrl+H to the status bar footer hint. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 029f3d6..db4ee8e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -11,8 +11,9 @@ use std::time::{Duration, Instant}; use crossterm::{ event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyboardEnhancementFlags, - KeyModifiers, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, + KeyboardEnhancementFlags, KeyModifiers, MouseEventKind, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -113,6 +114,9 @@ pub struct TuiApp { /// Active Ctrl+Space fuzzy search session; `None` when closed. fuzzy_state: Option, + /// Whether the Ctrl+H help overlay is visible (shown while key is held). + help_visible: bool, + /// Active Ctrl+R reverse-search session; `None` when not searching. history_search: Option, @@ -170,6 +174,7 @@ impl TuiApp { pending_schema_refresh: false, completion_state: None, fuzzy_state: None, + help_visible: false, history_search: None, needs_clear: false, should_quit: false, @@ -198,7 +203,10 @@ impl TuiApp { // Silently ignore on terminals that don't support it. let _ = execute!( stdout, - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES, + ) ); let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -344,6 +352,19 @@ impl TuiApp { /// Returns `true` when the app should exit. async fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + // ── Ctrl+H help overlay (hold-to-show) ─────────────────────────────── + // Handled before everything else so it works during queries too. + let is_ctrl_h = key.code == KeyCode::Char('h') + && key.modifiers.contains(KeyModifiers::CONTROL); + if is_ctrl_h { + self.help_visible = key.kind != KeyEventKind::Release; + return false; + } + // Any other key press dismisses the help overlay. + if self.help_visible && key.kind == KeyEventKind::Press { + self.help_visible = false; + } + // While a query is running only Ctrl+C is accepted (to cancel) if self.is_running { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { @@ -1055,7 +1076,10 @@ impl TuiApp { let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); let _ = execute!( terminal.backend_mut(), - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES, + ) ); let _ = enable_raw_mode(); let _ = std::io::Write::flush(terminal.backend_mut()); @@ -1294,6 +1318,11 @@ impl TuiApp { } } + // Ctrl+H help overlay (topmost — rendered above all other content) + if self.help_visible { + self.render_help_popup(f, area); + } + // Status bar self.render_status_bar(f, layout.status); } @@ -1419,6 +1448,62 @@ impl TuiApp { f.render_widget(Paragraph::new(vec![query_line, matched_line]), inner); } + fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { + use ratatui::{ + layout::Margin, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + }; + + const HELP: &str = "\ +Keyboard shortcuts +────────────────── +Ctrl+D Exit +Ctrl+C Cancel input / cancel running query +Ctrl+V Open last result in csvlens viewer +Ctrl+R Reverse history search +Ctrl+Space Fuzzy schema search +Tab Open / navigate completion popup +Shift+Tab Navigate completion popup backwards +Alt+F Format SQL (uppercase keywords, 2-space indent) +Page Up/Down Scroll output pane +Ctrl+Up/Down Cycle through history + +Special commands +──────────────── +\\help Show this help +\\set k=v Set a query parameter +\\unset k Remove a query parameter +\\benchmark [N] Run query N times (default 3) and report timings +\\export Export last result to CSV/JSON/TSV"; + + let lines: Vec = HELP + .lines() + .map(|l| ratatui::text::Line::from(l.to_string())) + .collect(); + let content_h = lines.len() as u16 + 2; // +2 for borders + let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(40) as u16 + 4; + + let popup_w = content_w.min(area.width.saturating_sub(4)); + let popup_h = content_h.min(area.height.saturating_sub(4)); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + let block = Block::default() + .title(" Help — release Ctrl+H to close ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + f.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .style(Style::default()), + inner, + ); + } + fn render_status_bar(&mut self, f: &mut ratatui::Frame, area: Rect) { let host = &self.context.args.host; let db = &self.context.args.database; @@ -1441,7 +1526,7 @@ impl TuiApp { } else if self.completion_state.is_some() { " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete Alt+F format ".to_string() + " Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete Alt+F format Ctrl+H help ".to_string() }; let total = area.width as usize; From cdbf18717346d90f800dcb2cdc3ef655691ef853 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:33:45 +0100 Subject: [PATCH 058/147] Restyle Ctrl+H help popup as floating window - Open on Ctrl+H press, close with Escape (replaces hold-to-show) - Double-border frame with steel-blue color and dark background - Shadow rect offset 1 cell for depth effect - Section headers in bold LightBlue, key names in LightYellow, commands in LightCyan, descriptions in White, separators in gray - "Press Esc to close" hint at bottom Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 174 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 53 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index db4ee8e..48d915b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -11,9 +11,8 @@ use std::time::{Duration, Instant}; use crossterm::{ event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, - KeyboardEnhancementFlags, KeyModifiers, MouseEventKind, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyboardEnhancementFlags, + KeyModifiers, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -203,10 +202,7 @@ impl TuiApp { // Silently ignore on terminals that don't support it. let _ = execute!( stdout, - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES, - ) + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) ); let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -352,17 +348,20 @@ impl TuiApp { /// Returns `true` when the app should exit. async fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { - // ── Ctrl+H help overlay (hold-to-show) ─────────────────────────────── + // ── Ctrl+H help overlay ────────────────────────────────────────────── // Handled before everything else so it works during queries too. - let is_ctrl_h = key.code == KeyCode::Char('h') - && key.modifiers.contains(KeyModifiers::CONTROL); - if is_ctrl_h { - self.help_visible = key.kind != KeyEventKind::Release; + if key.code == KeyCode::Char('h') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.help_visible = true; return false; } - // Any other key press dismisses the help overlay. - if self.help_visible && key.kind == KeyEventKind::Press { + // Escape closes the help overlay (and nothing else). + if self.help_visible && key.code == KeyCode::Esc { self.help_visible = false; + return false; + } + // While the overlay is open, swallow all other keys. + if self.help_visible { + return false; } // While a query is running only Ctrl+C is accepted (to cancel) @@ -1076,10 +1075,7 @@ impl TuiApp { let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); let _ = execute!( terminal.backend_mut(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES, - ) + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) ); let _ = enable_raw_mode(); let _ = std::io::Write::flush(terminal.backend_mut()); @@ -1450,56 +1446,128 @@ impl TuiApp { fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { use ratatui::{ - layout::Margin, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + style::Modifier, + text::Span, + widgets::{BorderType, Clear, Paragraph, Wrap}, }; - const HELP: &str = "\ -Keyboard shortcuts -────────────────── -Ctrl+D Exit -Ctrl+C Cancel input / cancel running query -Ctrl+V Open last result in csvlens viewer -Ctrl+R Reverse history search -Ctrl+Space Fuzzy schema search -Tab Open / navigate completion popup -Shift+Tab Navigate completion popup backwards -Alt+F Format SQL (uppercase keywords, 2-space indent) -Page Up/Down Scroll output pane -Ctrl+Up/Down Cycle through history - -Special commands -──────────────── -\\help Show this help -\\set k=v Set a query parameter -\\unset k Remove a query parameter -\\benchmark [N] Run query N times (default 3) and report timings -\\export Export last result to CSV/JSON/TSV"; - - let lines: Vec = HELP - .lines() - .map(|l| ratatui::text::Line::from(l.to_string())) - .collect(); - let content_h = lines.len() as u16 + 2; // +2 for borders - let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(40) as u16 + 4; - - let popup_w = content_w.min(area.width.saturating_sub(4)); + // Build styled help lines + let section_style = Style::default() + .fg(Color::LightBlue) + .add_modifier(Modifier::BOLD); + let sep_style = Style::default().fg(Color::Indexed(240)); + let key_style = Style::default().fg(Color::LightYellow); + let desc_style = Style::default().fg(Color::White); + let cmd_style = Style::default().fg(Color::LightCyan); + + let keybinds: &[(&str, &str)] = &[ + ("Ctrl+D", "Exit"), + ("Ctrl+C", "Cancel input / cancel running query"), + ("Ctrl+V", "Open last result in csvlens viewer"), + ("Ctrl+R", "Reverse history search"), + ("Ctrl+Space", "Fuzzy schema search"), + ("Tab", "Open / navigate completion popup"), + ("Shift+Tab", "Navigate completion popup backwards"), + ("Alt+F", "Format SQL (uppercase keywords, 2-space indent)"), + ("Page Up/Down", "Scroll output pane"), + ("Ctrl+Up/Down", "Cycle through history"), + ("Escape", "Close any open popup"), + ]; + let commands: &[(&str, &str)] = &[ + ("\\help", "Show this help"), + ("\\set k=v", "Set a query parameter"), + ("\\unset k", "Remove a query parameter"), + ("\\benchmark [N]", "Run query N times (default 3) and report timings"), + ("\\export ", "Export last result to CSV/JSON/TSV"), + ]; + + // Determine column widths + let key_col = keybinds.iter().chain(commands.iter()).map(|(k, _)| k.len()).max().unwrap_or(14) + 2; + + let mut lines: Vec = Vec::new(); + + // Section: keyboard shortcuts + lines.push(Line::from(Span::styled(" Keyboard shortcuts", section_style))); + lines.push(Line::from(Span::styled( + format!(" {}", "─".repeat(key_col + 30)), + sep_style, + ))); + for (key, desc) in keybinds { + let padding = key_col.saturating_sub(key.len()); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(key.to_string(), key_style), + Span::raw(" ".repeat(padding)), + Span::styled(desc.to_string(), desc_style), + ])); + } + + lines.push(Line::from("")); + + // Section: special commands + lines.push(Line::from(Span::styled(" Special commands", section_style))); + lines.push(Line::from(Span::styled( + format!(" {}", "─".repeat(key_col + 30)), + sep_style, + ))); + for (cmd, desc) in commands { + let padding = key_col.saturating_sub(cmd.len()); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(cmd.to_string(), cmd_style), + Span::raw(" ".repeat(padding)), + Span::styled(desc.to_string(), desc_style), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Press Esc to close", sep_style), + ])); + + // Sizing: fixed inner content width + borders + let content_w = (key_col + 2 + 36) as u16 + 4; // key col + desc + padding + borders + let content_h = lines.len() as u16 + 2; + + let popup_w = content_w.min(area.width.saturating_sub(6)); let popup_h = content_h.min(area.height.saturating_sub(4)); let x = area.x + area.width.saturating_sub(popup_w) / 2; let y = area.y + area.height.saturating_sub(popup_h) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); + // Shadow: render a dark rect shifted 1 cell right + 1 cell down + if popup_rect.right() + 1 < area.right() && popup_rect.bottom() + 1 < area.bottom() { + let shadow_rect = Rect::new( + popup_rect.x + 1, + popup_rect.y + 1, + popup_rect.width, + popup_rect.height, + ); + f.render_widget( + Paragraph::new("").style(Style::default().bg(Color::Indexed(232))), + shadow_rect, + ); + } + + // Main popup background + border f.render_widget(Clear, popup_rect); let block = Block::default() - .title(" Help — release Ctrl+H to close ") + .title(Span::styled( + " ✦ Help ", + Style::default().fg(Color::LightBlue).add_modifier(Modifier::BOLD), + )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_type(BorderType::Double) + .border_style(Style::default().fg(Color::Indexed(67))) // steel blue + .style(Style::default().bg(Color::Indexed(234))); // dark background + let inner = block.inner(popup_rect); f.render_widget(block, popup_rect); f.render_widget( Paragraph::new(lines) .wrap(Wrap { trim: false }) - .style(Style::default()), + .style(Style::default().bg(Color::Indexed(234))), inner, ); } From a6fb39b7cda14c7d4aa90b65ba1ec71955daa639 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 20 Feb 2026 20:54:32 +0100 Subject: [PATCH 059/147] Improve help popup: wider window, q to close, close hint in title Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 48d915b..045ffa3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -354,8 +354,11 @@ impl TuiApp { self.help_visible = true; return false; } - // Escape closes the help overlay (and nothing else). - if self.help_visible && key.code == KeyCode::Esc { + // Escape or q closes the help overlay (and nothing else). + if self.help_visible + && (key.code == KeyCode::Esc + || key.code == KeyCode::Char('q')) + { self.help_visible = false; return false; } @@ -1523,11 +1526,11 @@ impl TuiApp { lines.push(Line::from("")); lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("Press Esc to close", sep_style), + Span::styled("Press Esc or q to close", sep_style), ])); // Sizing: fixed inner content width + borders - let content_w = (key_col + 2 + 36) as u16 + 4; // key col + desc + padding + borders + let content_w = (key_col + 2 + 46) as u16 + 4; // key col + desc + padding + borders let content_h = lines.len() as u16 + 2; let popup_w = content_w.min(area.width.saturating_sub(6)); @@ -1554,7 +1557,7 @@ impl TuiApp { f.render_widget(Clear, popup_rect); let block = Block::default() .title(Span::styled( - " ✦ Help ", + " ✦ Help · Esc / q to close ", Style::default().fg(Color::LightBlue).add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) From 14169a06e5d0184fe23a88aaa50de767a771c073 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 09:54:06 +0100 Subject: [PATCH 060/147] Fix Ctrl+R history search: show query and popup with matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the search query wasn't visible while typing and no list of matching entries was shown. - HistorySearch now tracks all matches (Vec) + selected index instead of a single matched_idx - Popup renders above the input area (same pattern as fuzzy popup): - Search input line with '/ query█' prompt - Scrollable list of matching history entries, selected row highlighted - Up/Down arrows navigate the match list; Ctrl+R still cycles older - Normal textarea remains visible beneath the popup (draft preserved) Co-Authored-By: Claude Sonnet 4.6 --- src/tui/history_search.rs | 134 ++++++++++++++++++++++++------------- src/tui/mod.rs | 135 +++++++++++++++++++++++++++++--------- 2 files changed, 191 insertions(+), 78 deletions(-) diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index 41c6bc9..a2597bb 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -1,13 +1,18 @@ /// Incremental reverse-history-search state (Ctrl+R). /// -/// The search is case-insensitive substring match, walking backwards -/// through the history list (most-recent-first). Successive Ctrl+R presses -/// cycle to older matches. +/// Tracks all matching history entries for the current query so the popup +/// can display a navigable list. +pub const MAX_VISIBLE: usize = 12; + pub struct HistorySearch { /// The characters the user has typed so far. query: String, - /// Index into `History::entries` of the current match (None = no match). - matched_idx: Option, + /// Indices into `History::entries` of all matching entries, most-recent-first. + all_matches: Vec, + /// Which match is currently highlighted (index into `all_matches`). + selected: usize, + /// First visible row in the popup list. + scroll_offset: usize, /// Text that was in the textarea when search began; restored on Escape. saved_content: String, } @@ -16,7 +21,9 @@ impl HistorySearch { pub fn new(saved_content: String) -> Self { Self { query: String::new(), - matched_idx: None, + all_matches: Vec::new(), + selected: 0, + scroll_offset: 0, saved_content, } } @@ -25,9 +32,21 @@ impl HistorySearch { &self.query } - /// The matched history entry, if any. + pub fn all_matches(&self) -> &[usize] { + &self.all_matches + } + + pub fn selected(&self) -> usize { + self.selected + } + + pub fn scroll_offset(&self) -> usize { + self.scroll_offset + } + + /// The currently highlighted history entry, if any. pub fn matched<'a>(&self, entries: &'a [String]) -> Option<&'a str> { - self.matched_idx.map(|i| entries[i].as_str()) + self.all_matches.get(self.selected).map(|&i| entries[i].as_str()) } /// The textarea content to restore when the user presses Escape. @@ -35,46 +54,57 @@ impl HistorySearch { &self.saved_content } - // ── Search helpers ─────────────────────────────────────────────────────── + // ── Private helpers ────────────────────────────────────────────────────── + + fn recompute(&mut self, entries: &[String]) { + let q = self.query.to_lowercase(); + self.all_matches = (0..entries.len()) + .rev() // most-recent first + .filter(|&i| q.is_empty() || entries[i].to_lowercase().contains(&q)) + .collect(); + self.selected = 0; + self.scroll_offset = 0; + } - /// Search backwards through `entries[..before]` for an entry containing - /// `query_lower`. Returns the index of the first (most-recent) hit. - fn search_before(query_lower: &str, entries: &[String], before: usize) -> Option { - let end = before.min(entries.len()); - if query_lower.is_empty() { - // No query → match the most-recent entry unconditionally. - return if end > 0 { Some(end - 1) } else { None }; + fn ensure_visible(&mut self) { + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + MAX_VISIBLE { + self.scroll_offset = self.selected - MAX_VISIBLE + 1; } - (0..end).rev().find(|&i| entries[i].to_lowercase().contains(query_lower)) } // ── Public mutation ────────────────────────────────────────────────────── - /// Append `c` to the query and re-search from the most-recent entry. pub fn push_char(&mut self, c: char, entries: &[String]) { self.query.push(c); - let q = self.query.to_lowercase(); - // Re-search from the current match position (or end of list) so that - // adding a character narrows the match rather than jumping further back. - let before = self.matched_idx.map(|i| i + 1).unwrap_or(entries.len()); - self.matched_idx = Self::search_before(&q, entries, before); + self.recompute(entries); } - /// Remove the last character from the query and re-search from the end. pub fn pop_char(&mut self, entries: &[String]) { self.query.pop(); - let q = self.query.to_lowercase(); - // After narrowing the query the most-recent match might have moved - // forward, so restart from the end of the list. - self.matched_idx = Self::search_before(&q, entries, entries.len()); + self.recompute(entries); } - /// Cycle to the next older match (invoked by a second Ctrl+R). - pub fn search_older(&mut self, entries: &[String]) { - let q = self.query.to_lowercase(); - // Search strictly before the current match. - let before = self.matched_idx.unwrap_or(entries.len()); - self.matched_idx = Self::search_before(&q, entries, before); + /// Move selection to the next older match. + pub fn select_next(&mut self) { + if self.selected + 1 < self.all_matches.len() { + self.selected += 1; + self.ensure_visible(); + } + } + + /// Move selection to the next newer match. + pub fn select_prev(&mut self) { + if self.selected > 0 { + self.selected -= 1; + self.ensure_visible(); + } + } + + /// Alias used by Ctrl+R (cycles to next older match). + pub fn search_older(&mut self, _entries: &[String]) { + self.select_next(); } } @@ -92,14 +122,20 @@ mod tests { } #[test] - fn test_empty_query_matches_most_recent() { - let mut s = HistorySearch::new(String::new()); + fn test_empty_query_no_match_at_start() { + let s = HistorySearch::new(String::new()); let e = entries(); - // Without typing anything, matched_idx should be None (not started yet). + // Nothing typed → no match yet assert!(s.matched(&e).is_none()); - // After pushing then deleting (empty query), most-recent entry returned. + } + + #[test] + fn test_empty_query_after_pop_matches_most_recent() { + let mut s = HistorySearch::new(String::new()); + let e = entries(); s.push_char('x', &e); s.pop_char(&e); + // Empty query → all entries match; most-recent (index 3 = INSERT) is first assert_eq!(s.matched(&e), Some("INSERT INTO orders VALUES (1);")); } @@ -110,8 +146,7 @@ mod tests { s.push_char('s', &e); s.push_char('e', &e); s.push_char('l', &e); - // Most recent "sel" match should be index 3 (INSERT has no sel), - // index 2 "SELECT * FROM users;" → yes + // Most recent "sel" match: "SELECT * FROM users;" (index 2 in entries) assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); } @@ -119,21 +154,17 @@ mod tests { fn test_ctrl_r_cycles_older() { let mut s = HistorySearch::new(String::new()); let e = entries(); - // "sel" narrows to SELECT entries only (INSERT doesn't contain "sel") s.push_char('s', &e); s.push_char('e', &e); s.push_char('l', &e); - // First match: most-recent SELECT entry assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); s.search_older(&e); - // Next older: "SELECT * FROM orders;" (index 1) assert_eq!(s.matched(&e), Some("SELECT * FROM orders;")); s.search_older(&e); - // Next older: "SELECT 1;" (index 0) assert_eq!(s.matched(&e), Some("SELECT 1;")); + // Already at last match — stays there s.search_older(&e); - // No more matches — stays at None - assert!(s.matched(&e).is_none()); + assert_eq!(s.matched(&e), Some("SELECT 1;")); } #[test] @@ -141,4 +172,15 @@ mod tests { let s = HistorySearch::new("my draft query".to_string()); assert_eq!(s.saved_content(), "my draft query"); } + + #[test] + fn test_all_matches_count() { + let mut s = HistorySearch::new(String::new()); + let e = entries(); + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + // "sel" matches 3 SELECT entries + assert_eq!(s.all_matches().len(), 3); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 045ffa3..6099b23 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -541,12 +541,24 @@ impl TuiApp { let entries = self.history.entries().to_vec(); match (key.code, key.modifiers) { - // Ctrl+R: cycle to next older match + // Ctrl+R or Down: cycle to next older match (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { if let Some(hs) = &mut self.history_search { hs.search_older(&entries); } } + (KeyCode::Down, _) => { + if let Some(hs) = &mut self.history_search { + hs.select_next(); + } + } + + // Up: cycle to next newer match + (KeyCode::Up, _) => { + if let Some(hs) = &mut self.history_search { + hs.select_prev(); + } + } // Escape or Ctrl+G: cancel, restore saved content (KeyCode::Esc, _) @@ -1270,8 +1282,6 @@ impl TuiApp { if self.is_running { self.render_running_pane(f, layout.input); - } else if let Some(hs) = &self.history_search { - self.render_history_search_pane(f, layout.input, hs); } else { // Input area: outer block provides top + bottom separator lines let outer_block = Block::default() @@ -1295,20 +1305,39 @@ impl TuiApp { let textarea_area = chunks[1]; self.apply_textarea_highlights(f.buffer_mut(), textarea_area); - // Render completion popup if open - if let Some(cs) = &self.completion_state { - if !cs.is_empty() { - let popup_rect = completion_popup::popup_area( - cs, - layout.input, - chunks[1].x, - area, - ); - completion_popup::render(cs, popup_rect, f); + // Render completion popup if open (not during history search) + if self.history_search.is_none() { + if let Some(cs) = &self.completion_state { + if !cs.is_empty() { + let popup_rect = completion_popup::popup_area( + cs, + layout.input, + chunks[1].x, + area, + ); + completion_popup::render(cs, popup_rect, f); + } } } } + // History search popup (above input area, like fuzzy popup) + if self.history_search.is_some() { + let popup_h = (history_search::MAX_VISIBLE as u16 + 4).min(layout.input.y); + if popup_h > 2 { + let popup_rect = Rect::new( + 0, + layout.input.y.saturating_sub(popup_h), + area.width, + popup_h, + ); + // Borrow separately to avoid simultaneous &self + &mut self + let hs = self.history_search.as_ref().unwrap(); + let entries: Vec = self.history.entries().to_vec(); + Self::render_history_search_popup(f, popup_rect, hs, &entries); + } + } + // Fuzzy search overlay (rendered on top of everything) if let Some(fs) = &self.fuzzy_state { let fuzzy_rect = fuzzy_popup::popup_area(layout.input, area); @@ -1422,29 +1451,71 @@ impl TuiApp { f.render_widget(Paragraph::new(lines), inner); } - fn render_history_search_pane(&self, f: &mut ratatui::Frame, area: Rect, hs: &HistorySearch) { - let outer_block = Block::default() - .borders(Borders::TOP | Borders::BOTTOM) - .border_style(Style::default().fg(Color::Cyan)); - let inner = outer_block.inner(area); - f.render_widget(outer_block, area); + fn render_history_search_popup( + f: &mut ratatui::Frame, + area: Rect, + hs: &HistorySearch, + entries: &[String], + ) { + use ratatui::{ + style::Modifier, + widgets::{Clear, List, ListItem}, + }; - let entries = self.history.entries(); - let matched = hs.matched(entries).unwrap_or(""); + f.render_widget(Clear, area); - // First line: "(reverse-i-search)`query':" - let query_line = Line::from(vec![ - Span::styled("(reverse-i-search)`", Style::default().fg(Color::DarkGray)), - Span::styled(hs.query().to_string(), Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD)), - Span::styled("':", Style::default().fg(Color::DarkGray)), - ]); + let block = Block::default() + .title(" History Search (Enter accept · ↑/↓ navigate · Ctrl+R older · Esc close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); - // Second line: the matched entry (truncated to fit) - let width = inner.width as usize; - let display: String = matched.chars().take(width).collect(); - let matched_line = Line::from(Span::styled(display, Style::default().fg(Color::Yellow))); + let inner = block.inner(area); + f.render_widget(block, area); + + // Split: search input line at top, match list below + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(inner); + + // Search line with blinking-block cursor indicator + let search_line = Line::from(vec![ + Span::styled("/ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(hs.query().to_string(), Style::default().fg(Color::White)), + Span::styled("█", Style::default().fg(Color::Cyan)), + ]); + f.render_widget(Paragraph::new(search_line), chunks[0]); + + // Match list + let list_h = chunks[1].height as usize; + let list_w = chunks[1].width as usize; + + let items: Vec = hs + .all_matches() + .iter() + .enumerate() + .skip(hs.scroll_offset()) + .take(list_h) + .map(|(idx, &entry_idx)| { + let is_sel = idx == hs.selected(); + let entry = &entries[entry_idx]; + // Show only the first line, truncated + let first_line = entry.lines().next().unwrap_or(entry.as_str()); + let truncated: String = first_line.chars().take(list_w.saturating_sub(1)).collect(); + let style = if is_sel { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + ListItem::new(Line::from(Span::styled(truncated, style))) + }) + .collect(); - f.render_widget(Paragraph::new(vec![query_line, matched_line]), inner); + f.render_widget(List::new(items), chunks[1]); } fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { From f9179e1db4c117f2369b056b01826501aeb5f2f6 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 10:06:36 +0100 Subject: [PATCH 061/147] Improve Ctrl+R history search UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bottom-up layout: search box at the bottom, matches listed above it with the most recent entry just above the search box - Show all recent queries immediately (no query needed to see history) - Live preview: selected match is shown in the textarea as you navigate up/down; Escape restores the original draft - Multi-line entries are collapsed to one line with ↵ separators and truncated with (...) if too wide; full text still used for matching - Up/Ctrl+R = older, Down = newer (matches bottom-up visual layout) Co-Authored-By: Claude Sonnet 4.6 --- src/tui/history_search.rs | 149 +++++++++++++++++++++++++------------- src/tui/mod.rs | 131 +++++++++++++++++++-------------- 2 files changed, 177 insertions(+), 103 deletions(-) diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index a2597bb..8a1f71a 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -1,31 +1,35 @@ /// Incremental reverse-history-search state (Ctrl+R). /// -/// Tracks all matching history entries for the current query so the popup -/// can display a navigable list. -pub const MAX_VISIBLE: usize = 12; +/// Displayed as a bottom-up popup: the search box is at the bottom and +/// matches are listed above it with the most-recent entry at the bottom. +pub const MAX_VISIBLE: usize = 16; pub struct HistorySearch { /// The characters the user has typed so far. query: String, /// Indices into `History::entries` of all matching entries, most-recent-first. all_matches: Vec, - /// Which match is currently highlighted (index into `all_matches`). + /// Which match is currently highlighted (index into `all_matches`, 0 = most recent). selected: usize, - /// First visible row in the popup list. + /// Scroll offset: index of the entry shown at the bottom of the visible window. scroll_offset: usize, /// Text that was in the textarea when search began; restored on Escape. saved_content: String, } impl HistorySearch { - pub fn new(saved_content: String) -> Self { - Self { + /// Create a new search session. Immediately populates all matches so that + /// the popup shows recent history before the user types anything. + pub fn new(saved_content: String, entries: &[String]) -> Self { + let mut s = Self { query: String::new(), all_matches: Vec::new(), selected: 0, scroll_offset: 0, saved_content, - } + }; + s.recompute(entries); + s } pub fn query(&self) -> &str { @@ -66,11 +70,13 @@ impl HistorySearch { self.scroll_offset = 0; } - fn ensure_visible(&mut self) { + /// Keep `selected` within the visible scroll window. + fn ensure_visible(&mut self, visible_rows: usize) { + let h = visible_rows.max(1); if self.selected < self.scroll_offset { self.scroll_offset = self.selected; - } else if self.selected >= self.scroll_offset + MAX_VISIBLE { - self.scroll_offset = self.selected - MAX_VISIBLE + 1; + } else if self.selected >= self.scroll_offset + h { + self.scroll_offset = self.selected - h + 1; } } @@ -86,25 +92,54 @@ impl HistorySearch { self.recompute(entries); } - /// Move selection to the next older match. - pub fn select_next(&mut self) { + /// Move selection to the next older match (Up arrow / Ctrl+R). + /// `visible_rows` is the height of the list area for scroll tracking. + pub fn select_older(&mut self, visible_rows: usize) { if self.selected + 1 < self.all_matches.len() { self.selected += 1; - self.ensure_visible(); + self.ensure_visible(visible_rows); } } - /// Move selection to the next newer match. - pub fn select_prev(&mut self) { + /// Move selection to the next newer match (Down arrow). + pub fn select_newer(&mut self, visible_rows: usize) { if self.selected > 0 { self.selected -= 1; - self.ensure_visible(); + self.ensure_visible(visible_rows); } } - /// Alias used by Ctrl+R (cycles to next older match). - pub fn search_older(&mut self, _entries: &[String]) { - self.select_next(); + /// Alias for Ctrl+R (cycles to next older match). + pub fn search_older(&mut self, _entries: &[String], visible_rows: usize) { + self.select_older(visible_rows); + } +} + +/// Format a (possibly multi-line) history entry for single-line display. +/// Newlines are replaced with `↵` and the result is truncated with `(...)`. +pub fn format_entry_oneline(entry: &str, max_chars: usize) -> String { + // Join lines with ↵ separator + let joined: String = { + let mut parts = entry.lines(); + let first = parts.next().unwrap_or("").trim_end(); + let mut s = first.to_string(); + for part in parts { + let trimmed = part.trim(); + if !trimmed.is_empty() { + s.push_str(" ↵ "); + s.push_str(trimmed); + } + } + s + }; + + if joined.chars().count() <= max_chars { + joined + } else { + let suffix = " (...)"; + let take = max_chars.saturating_sub(suffix.chars().count()); + let truncated: String = joined.chars().take(take).collect(); + format!("{}{}", truncated, suffix) } } @@ -122,65 +157,79 @@ mod tests { } #[test] - fn test_empty_query_no_match_at_start() { - let s = HistorySearch::new(String::new()); - let e = entries(); - // Nothing typed → no match yet - assert!(s.matched(&e).is_none()); - } - - #[test] - fn test_empty_query_after_pop_matches_most_recent() { - let mut s = HistorySearch::new(String::new()); + fn test_new_shows_all_entries() { + let s = HistorySearch::new(String::new(), &entries()); let e = entries(); - s.push_char('x', &e); - s.pop_char(&e); - // Empty query → all entries match; most-recent (index 3 = INSERT) is first + // No query → all entries match; most-recent (INSERT) is first + assert_eq!(s.all_matches().len(), 4); assert_eq!(s.matched(&e), Some("INSERT INTO orders VALUES (1);")); } #[test] fn test_push_narrows_match() { - let mut s = HistorySearch::new(String::new()); + let mut s = HistorySearch::new(String::new(), &entries()); let e = entries(); s.push_char('s', &e); s.push_char('e', &e); s.push_char('l', &e); - // Most recent "sel" match: "SELECT * FROM users;" (index 2 in entries) + assert_eq!(s.all_matches().len(), 3); assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); } #[test] - fn test_ctrl_r_cycles_older() { - let mut s = HistorySearch::new(String::new()); + fn test_select_older_cycles() { + let mut s = HistorySearch::new(String::new(), &entries()); let e = entries(); s.push_char('s', &e); s.push_char('e', &e); s.push_char('l', &e); assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); - s.search_older(&e); + s.select_older(100); assert_eq!(s.matched(&e), Some("SELECT * FROM orders;")); - s.search_older(&e); + s.select_older(100); assert_eq!(s.matched(&e), Some("SELECT 1;")); - // Already at last match — stays there - s.search_older(&e); + // At last match — stays there + s.select_older(100); assert_eq!(s.matched(&e), Some("SELECT 1;")); } + #[test] + fn test_select_newer() { + let mut s = HistorySearch::new(String::new(), &entries()); + let e = entries(); + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + s.select_older(100); + s.select_older(100); + assert_eq!(s.matched(&e), Some("SELECT 1;")); + s.select_newer(100); + assert_eq!(s.matched(&e), Some("SELECT * FROM orders;")); + } + #[test] fn test_saved_content() { - let s = HistorySearch::new("my draft query".to_string()); + let s = HistorySearch::new("my draft query".to_string(), &[]); assert_eq!(s.saved_content(), "my draft query"); } #[test] - fn test_all_matches_count() { - let mut s = HistorySearch::new(String::new()); - let e = entries(); - s.push_char('s', &e); - s.push_char('e', &e); - s.push_char('l', &e); - // "sel" matches 3 SELECT entries - assert_eq!(s.all_matches().len(), 3); + fn test_format_entry_oneline_single() { + assert_eq!(format_entry_oneline("SELECT 1;", 80), "SELECT 1;"); + } + + #[test] + fn test_format_entry_oneline_multiline() { + let s = format_entry_oneline("SELECT 1\nFROM t", 80); + assert!(s.contains("↵")); + assert!(s.contains("FROM t")); + } + + #[test] + fn test_format_entry_oneline_truncate() { + let long = "SELECT very_long_column_name FROM some_table WHERE condition IS TRUE"; + let result = format_entry_oneline(long, 20); + assert!(result.ends_with("(...)")); + assert!(result.chars().count() <= 20); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6099b23..33cfeba 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -402,7 +402,10 @@ impl TuiApp { // ── Ctrl+R: start reverse history search ───────────────────── (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { let saved = self.textarea.lines().join("\n"); - self.history_search = Some(HistorySearch::new(saved)); + let entries = self.history.entries().to_vec(); + self.history_search = Some(HistorySearch::new(saved, &entries)); + // Immediately preview the most recent match + self.preview_history_match(&entries); } // ── Cancel / clear ──────────────────────────────────────────── @@ -540,24 +543,30 @@ impl TuiApp { async fn handle_history_search_key(&mut self, key: crossterm::event::KeyEvent) -> bool { let entries = self.history.entries().to_vec(); + // Compute the visible list height for scroll tracking (approximation) + let list_h = history_search::MAX_VISIBLE; + match (key.code, key.modifiers) { - // Ctrl+R or Down: cycle to next older match + // Ctrl+R or Up arrow: cycle to next older match (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { if let Some(hs) = &mut self.history_search { - hs.search_older(&entries); + hs.search_older(&entries, list_h); } + self.preview_history_match(&entries); } - (KeyCode::Down, _) => { + (KeyCode::Up, _) => { if let Some(hs) = &mut self.history_search { - hs.select_next(); + hs.select_older(list_h); } + self.preview_history_match(&entries); } - // Up: cycle to next newer match - (KeyCode::Up, _) => { + // Down arrow: cycle to next newer match + (KeyCode::Down, _) => { if let Some(hs) = &mut self.history_search { - hs.select_prev(); + hs.select_newer(list_h); } + self.preview_history_match(&entries); } // Escape or Ctrl+G: cancel, restore saved content @@ -577,16 +586,10 @@ impl TuiApp { self.reset_textarea(); } - // Enter: accept the current match + // Enter: accept — textarea already shows the preview (KeyCode::Enter, _) => { - let matched = self - .history_search - .as_ref() - .and_then(|hs| hs.matched(&entries)) - .map(|s| s.to_string()); self.history_search = None; - let content = matched.unwrap_or_default(); - self.set_textarea_content(&content); + // textarea already has the previewed content; nothing else to do } // Backspace: remove one character from the query @@ -594,6 +597,7 @@ impl TuiApp { if let Some(hs) = &mut self.history_search { hs.pop_char(&entries); } + self.preview_history_match(&entries); } // Printable character: append to search query @@ -601,20 +605,13 @@ impl TuiApp { if let Some(hs) = &mut self.history_search { hs.push_char(c, &entries); } + self.preview_history_match(&entries); } // Any other key: accept match, exit search, re-process key normally _ => { - let matched = self - .history_search - .as_ref() - .and_then(|hs| hs.matched(&entries)) - .map(|s| s.to_string()); self.history_search = None; - if let Some(content) = matched { - self.set_textarea_content(&content); - } - // Re-dispatch to normal handler + // textarea already has the previewed content return Box::pin(self.handle_key(key)).await; } } @@ -622,6 +619,23 @@ impl TuiApp { false } + /// Update the textarea to show a preview of the currently selected history match. + /// On no match, restores the saved content. + fn preview_history_match(&mut self, entries: &[String]) { + let content = self + .history_search + .as_ref() + .and_then(|hs| hs.matched(entries)) + .map(|s| s.to_string()) + .or_else(|| { + self.history_search + .as_ref() + .map(|hs| hs.saved_content().to_string()) + }) + .unwrap_or_default(); + self.set_textarea_content(&content); + } + // ── Tab completion ─────────────────────────────────────────────────────── /// Called on Tab when no popup is open: computes candidates and opens the popup. @@ -1472,50 +1486,61 @@ impl TuiApp { let inner = block.inner(area); f.render_widget(block, area); - // Split: search input line at top, match list below + // Bottom-up layout: match list above, search line at bottom let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(0), + Constraint::Min(0), // match list (fills remaining space) + Constraint::Length(1), // search input line ]) .split(inner); - // Search line with blinking-block cursor indicator + // ── Search line (bottom) ──────────────────────────────────────────── let search_line = Line::from(vec![ Span::styled("/ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::styled(hs.query().to_string(), Style::default().fg(Color::White)), Span::styled("█", Style::default().fg(Color::Cyan)), ]); - f.render_widget(Paragraph::new(search_line), chunks[0]); + f.render_widget(Paragraph::new(search_line), chunks[1]); - // Match list - let list_h = chunks[1].height as usize; - let list_w = chunks[1].width as usize; + // ── Match list (above, rendered bottom-to-top) ───────────────────── + let list_h = chunks[0].height as usize; + let list_w = chunks[0].width as usize; + let all = hs.all_matches(); + let offset = hs.scroll_offset(); - let items: Vec = hs - .all_matches() + // Collect the visible slice then reverse it so most-recent is at bottom + let visible: Vec<(usize, usize)> = all .iter() .enumerate() - .skip(hs.scroll_offset()) + .skip(offset) .take(list_h) - .map(|(idx, &entry_idx)| { - let is_sel = idx == hs.selected(); - let entry = &entries[entry_idx]; - // Show only the first line, truncated - let first_line = entry.lines().next().unwrap_or(entry.as_str()); - let truncated: String = first_line.chars().take(list_w.saturating_sub(1)).collect(); - let style = if is_sel { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - ListItem::new(Line::from(Span::styled(truncated, style))) - }) + .map(|(idx, &entry_idx)| (idx, entry_idx)) + .collect::>() + .into_iter() + .rev() .collect(); - f.render_widget(List::new(items), chunks[1]); + // Pad with blank rows at the top so results are bottom-aligned + let pad = list_h.saturating_sub(visible.len()); + let mut items: Vec = (0..pad) + .map(|_| ListItem::new(Line::from(""))) + .collect(); + + for (idx, entry_idx) in &visible { + let is_sel = *idx == hs.selected(); + let entry = &entries[*entry_idx]; + let display = history_search::format_entry_oneline(entry, list_w.saturating_sub(1)); + let style = if is_sel { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + items.push(ListItem::new(Line::from(Span::styled(display, style)))); + } + + f.render_widget(List::new(items), chunks[0]); } fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { From 819aacd4cadf3bcdd0a1fa3c3216e71f77e24e5c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 10:22:33 +0100 Subject: [PATCH 062/147] Remove consecutive duplicates from history search popup Co-Authored-By: Claude Sonnet 4.6 --- src/tui/history_search.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index 8a1f71a..6b25fca 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -62,10 +62,21 @@ impl HistorySearch { fn recompute(&mut self, entries: &[String]) { let q = self.query.to_lowercase(); - self.all_matches = (0..entries.len()) - .rev() // most-recent first - .filter(|&i| q.is_empty() || entries[i].to_lowercase().contains(&q)) - .collect(); + let mut result = Vec::new(); + let mut last_seen = ""; + for i in (0..entries.len()).rev() { + let entry = entries[i].as_str(); + // Skip entries that are identical to the one immediately after them + // in chronological order (consecutive duplicates in history). + if entry == last_seen { + continue; + } + last_seen = entry; + if q.is_empty() || entry.to_lowercase().contains(&q) { + result.push(i); + } + } + self.all_matches = result; self.selected = 0; self.scroll_offset = 0; } @@ -232,4 +243,19 @@ mod tests { assert!(result.ends_with("(...)")); assert!(result.chars().count() <= 20); } + + #[test] + fn test_consecutive_duplicates_removed() { + let e = vec![ + "SELECT 1;".to_string(), + "SELECT 2;".to_string(), + "SELECT 2;".to_string(), + "SELECT 2;".to_string(), + "SELECT 1;".to_string(), + ]; + let s = HistorySearch::new(String::new(), &e); + // "SELECT 2;" runs 3× consecutively → shown once; "SELECT 1;" at both + // ends is not consecutive with SELECT 2 → shown twice. + assert_eq!(s.all_matches().len(), 3); + } } From 5c325e94485eb62ccee28dd13ef3c177b4b7e885 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 10:24:00 +0100 Subject: [PATCH 063/147] Deduplicate history search results by content, not just consecutive Use a HashSet to keep only the most recent occurrence of each unique query, so duplicates never appear regardless of search filter. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/history_search.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index 6b25fca..5f42bef 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -63,15 +63,13 @@ impl HistorySearch { fn recompute(&mut self, entries: &[String]) { let q = self.query.to_lowercase(); let mut result = Vec::new(); - let mut last_seen = ""; + let mut seen = std::collections::HashSet::new(); for i in (0..entries.len()).rev() { let entry = entries[i].as_str(); - // Skip entries that are identical to the one immediately after them - // in chronological order (consecutive duplicates in history). - if entry == last_seen { + // Keep only the most-recent occurrence of each unique query. + if !seen.insert(entry) { continue; } - last_seen = entry; if q.is_empty() || entry.to_lowercase().contains(&q) { result.push(i); } @@ -245,7 +243,7 @@ mod tests { } #[test] - fn test_consecutive_duplicates_removed() { + fn test_duplicates_removed() { let e = vec![ "SELECT 1;".to_string(), "SELECT 2;".to_string(), @@ -254,8 +252,7 @@ mod tests { "SELECT 1;".to_string(), ]; let s = HistorySearch::new(String::new(), &e); - // "SELECT 2;" runs 3× consecutively → shown once; "SELECT 1;" at both - // ends is not consecutive with SELECT 2 → shown twice. - assert_eq!(s.all_matches().len(), 3); + // Only 2 unique queries; most-recent occurrence of each is kept. + assert_eq!(s.all_matches().len(), 2); } } From 91ccdebdff64d63e0c53e33c3d6bcbb3c3d84fd8 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 10:25:36 +0100 Subject: [PATCH 064/147] Deduplicate consecutive entries in filtered history results Two identical queries are collapsed only when they appear next to each other in the result list. If a different matching query appears between them, both are shown. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/history_search.rs | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index 5f42bef..aa06791 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -63,15 +63,16 @@ impl HistorySearch { fn recompute(&mut self, entries: &[String]) { let q = self.query.to_lowercase(); let mut result = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut last_matched = ""; for i in (0..entries.len()).rev() { let entry = entries[i].as_str(); - // Keep only the most-recent occurrence of each unique query. - if !seen.insert(entry) { - continue; - } if q.is_empty() || entry.to_lowercase().contains(&q) { - result.push(i); + // Skip only if identical to the previous entry that also matched — + // i.e. consecutive duplicates within the filtered result list. + if entry != last_matched { + result.push(i); + } + last_matched = entry; } } self.all_matches = result; @@ -243,16 +244,24 @@ mod tests { } #[test] - fn test_duplicates_removed() { + fn test_consecutive_duplicates_in_results() { let e = vec![ "SELECT 1;".to_string(), "SELECT 2;".to_string(), - "SELECT 2;".to_string(), - "SELECT 2;".to_string(), - "SELECT 1;".to_string(), + "SELECT 2;".to_string(), // consecutive with above → collapsed + "SELECT 2;".to_string(), // consecutive → collapsed + "SELECT 1;".to_string(), // not consecutive with SELECT 1 at index 0 + // because SELECT 2 entries are between them ]; + // No filter: result list is SELECT 1, SELECT 2, SELECT 2, SELECT 2, SELECT 1 + // (most-recent-first). After consecutive dedup in results: SELECT 1, SELECT 2, SELECT 1 let s = HistorySearch::new(String::new(), &e); - // Only 2 unique queries; most-recent occurrence of each is kept. - assert_eq!(s.all_matches().len(), 2); + assert_eq!(s.all_matches().len(), 3); + + // Filter "2": only SELECT 2 entries match → all three are consecutive in + // the result list → collapsed to one. + let mut s2 = HistorySearch::new(String::new(), &e); + s2.push_char('2', &e); + assert_eq!(s2.all_matches().len(), 1); } } From ceedc49825b6eb9630de60244911e7885a125bb4 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 10:27:24 +0100 Subject: [PATCH 065/147] Apply SQL syntax highlighting to history search popup entries Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 33cfeba..9c49028 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1348,7 +1348,7 @@ impl TuiApp { // Borrow separately to avoid simultaneous &self + &mut self let hs = self.history_search.as_ref().unwrap(); let entries: Vec = self.history.entries().to_vec(); - Self::render_history_search_popup(f, popup_rect, hs, &entries); + Self::render_history_search_popup(f, popup_rect, hs, &entries, &self.highlighter); } } @@ -1470,6 +1470,7 @@ impl TuiApp { area: Rect, hs: &HistorySearch, entries: &[String], + highlighter: &SqlHighlighter, ) { use ratatui::{ style::Modifier, @@ -1529,15 +1530,46 @@ impl TuiApp { let is_sel = *idx == hs.selected(); let entry = &entries[*entry_idx]; let display = history_search::format_entry_oneline(entry, list_w.saturating_sub(1)); - let style = if is_sel { - Style::default() + + let line = if is_sel { + // Selected row: solid cyan background, no syntax colours + let sel_style = Style::default() .fg(Color::Black) .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::BOLD); + Line::from(Span::styled(display, sel_style)) } else { - Style::default().fg(Color::White) + // Normal row: apply SQL syntax highlighting to the display text + let spans_meta = highlighter.highlight_to_spans(&display); + if spans_meta.is_empty() { + Line::from(Span::styled(display, Style::default().fg(Color::White))) + } else { + let mut parts: Vec = Vec::new(); + let mut last = 0usize; + for (range, style) in &spans_meta { + let s = range.start.min(display.len()); + let e = range.end.min(display.len()); + if s > last { + parts.push(Span::styled( + display[last..s].to_string(), + Style::default().fg(Color::White), + )); + } + if s < e { + parts.push(Span::styled(display[s..e].to_string(), *style)); + } + last = e; + } + if last < display.len() { + parts.push(Span::styled( + display[last..].to_string(), + Style::default().fg(Color::White), + )); + } + Line::from(parts) + } }; - items.push(ListItem::new(Line::from(Span::styled(display, style)))); + items.push(ListItem::new(line)); } f.render_widget(List::new(items), chunks[0]); From e6a05e355cb948eee617fc2dfd8e160af207e5e2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 13:18:56 +0100 Subject: [PATCH 066/147] Remove version from status bar footer Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9c49028..edfdf7f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1707,7 +1707,7 @@ impl TuiApp { let host = &self.context.args.host; let db = &self.context.args.database; - let conn_info = format!(" {} | {} | v{}", host, db, CLI_VERSION); + let conn_info = format!(" {} | {}", host, db); // Expire flash messages older than 2 seconds. if let Some((_, t)) = &self.flash_message { From 38459172a51a3dff4b3a6cd19bec832e036cdd60 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 13:39:29 +0100 Subject: [PATCH 067/147] Add function signature hint popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a small popup just above the input area whenever the cursor is inside a function call (e.g. `upper(`). The popup lists all known overload signatures for that function and disappears as soon as the cursor leaves the closing `)`. - signature_hint.rs: walks SQL up to cursor tracking paren depth to detect the innermost function call; handles strings, quoted identifiers, line/block comments and nested calls - schema_cache: queries routine_name + routine_parameters (array) from information_schema.routines and builds a map of function name → [signature strings]; silently ignored if the column doesn't exist - TuiApp: recomputes hint after every keystroke, renders a bordered popup (right-aligned above input) with function name in cyan and parameter types in white; hidden during history search, fuzzy search, help overlay and while a query is running Co-Authored-By: Claude Sonnet 4.6 --- src/completion/schema_cache.rs | 83 +++++++++++ src/tui/mod.rs | 116 ++++++++++++++++ src/tui/signature_hint.rs | 243 +++++++++++++++++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 src/tui/signature_hint.rs diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 6586e0b..f3bd392 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -20,6 +20,9 @@ pub struct ColumnMetadata { pub struct SchemaCache { tables: Arc>>, functions: Arc>>, + /// Maps lowercase function name → list of signature strings, e.g. + /// `"upper"` → `["upper(text)"]`. Empty until a successful parameters query. + function_signatures: Arc>>>, keywords: HashSet, last_refresh: Arc>>, ttl: Duration, @@ -31,6 +34,7 @@ impl SchemaCache { Self { tables: Arc::new(RwLock::new(HashMap::new())), functions: Arc::new(RwLock::new(HashSet::new())), + function_signatures: Arc::new(RwLock::new(HashMap::new())), keywords: Self::load_keywords(), last_refresh: Arc::new(RwLock::new(None)), ttl: Duration::from_secs(ttl_seconds), @@ -292,6 +296,15 @@ impl SchemaCache { result } + /// Return the known signatures for a function (lowercase name). + /// Returns an empty Vec if no signature data was loaded from the server. + pub fn get_signatures(&self, func_name: &str) -> Vec { + let sigs = self.function_signatures.read().unwrap(); + sigs.get(&func_name.to_lowercase()) + .cloned() + .unwrap_or_default() + } + /// Synchronous method to get completions from cache pub fn get_completions( &self, @@ -431,6 +444,15 @@ impl SchemaCache { let functions_result = query_silent(context, functions_query).await; + // Query function signatures from information_schema.routines. + // `routine_parameters` is an array column containing the parameter types + // for each overload, e.g. `["text"]` or `["anyelement","anyelement"]`. + let signatures_query = "SELECT routine_name, routine_parameters \ + FROM information_schema.routines \ + WHERE routine_type != 'OPERATOR' \ + ORDER BY routine_name"; + let signatures_result = query_silent(context, signatures_query).await; + // Parse and populate cache let mut new_tables = HashMap::new(); @@ -507,6 +529,16 @@ impl SchemaCache { } } + // Parse function signatures (best-effort; silently ignored on failure) + match signatures_result { + Ok(sig_output) => { + if let Some(sig_map) = Self::parse_function_signatures(&sig_output) { + *self.function_signatures.write().unwrap() = sig_map; + } + } + Err(_) => { /* information_schema.parameters not available — that's fine */ } + } + Ok(()) } @@ -661,6 +693,57 @@ impl SchemaCache { Some(result) } } + + /// Parse `(routine_name, routine_parameters)` rows from `information_schema.routines` + /// into a map of lowercase function name → list of signature strings. + /// + /// `routine_parameters` is a JSON array of parameter type strings per overload, + /// e.g. `["text"]` or `["anyelement", "anyelement"]`. + fn parse_function_signatures(output: &str) -> Option>> { + let mut sig_map: HashMap> = HashMap::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + + if let Ok(json) = serde_json::from_str::(line) { + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(arr) = row.as_array() { + if arr.len() < 2 { continue; } + let rname = arr[0].as_str().unwrap_or("").trim().to_string(); + if rname.is_empty() { continue; } + + // routine_parameters is a JSON array of type strings + let params: Vec = arr[1] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + + let sig = format!("{}({})", rname, params.join(", ")); + let entry = sig_map + .entry(rname.to_lowercase()) + .or_default(); + if !entry.contains(&sig) { + entry.push(sig); + } + } + } + } + } + } + } + } + + if sig_map.is_empty() { None } else { Some(sig_map) } + } } #[cfg(test)] diff --git a/src/tui/mod.rs b/src/tui/mod.rs index edfdf7f..7f17eb6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -4,6 +4,7 @@ pub mod history; pub mod history_search; pub mod layout; pub mod output_pane; +pub mod signature_hint; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -119,6 +120,10 @@ pub struct TuiApp { /// Active Ctrl+R reverse-search session; `None` when not searching. history_search: Option, + /// Current function-signature hint: (func_name, [sig1, sig2, ...]). + /// `None` when the cursor is not inside a function call. + signature_hint: Option<(String, Vec)>, + // After leaving alt-screen for csvlens we need a full redraw needs_clear: bool, should_quit: bool, @@ -175,6 +180,7 @@ impl TuiApp { fuzzy_state: None, help_visible: false, history_search: None, + signature_hint: None, needs_clear: false, should_quit: false, has_error: false, @@ -261,6 +267,7 @@ impl TuiApp { if self.handle_key(key).await { break; } + self.update_signature_hint(); } Event::Mouse(mouse) => match mouse.kind { @@ -636,6 +643,43 @@ impl TuiApp { self.set_textarea_content(&content); } + // ── Signature hint ─────────────────────────────────────────────────────── + + /// Recompute which function (if any) the cursor is currently inside and + /// update `self.signature_hint` accordingly. + fn update_signature_hint(&mut self) { + // Don't show during overlays or while a query is running + if self.is_running + || self.history_search.is_some() + || self.fuzzy_state.is_some() + || self.help_visible + || self.context.args.no_completion + { + self.signature_hint = None; + return; + } + + let lines = self.textarea.lines(); + let (cursor_row, cursor_col) = self.textarea.cursor(); + // Compute cursor byte offset in the joined SQL + let byte_offset: usize = lines[..cursor_row] + .iter() + .map(|l| l.len() + 1) // +1 for \n + .sum::() + + cursor_col; + let full_sql = lines.join("\n"); + + match signature_hint::detect_function_at_cursor(&full_sql, byte_offset) { + Some(func_name) => { + let sigs = self.schema_cache.get_signatures(&func_name); + self.signature_hint = Some((func_name, sigs)); + } + None => { + self.signature_hint = None; + } + } + } + // ── Tab completion ─────────────────────────────────────────────────────── /// Called on Tab when no popup is open: computes candidates and opens the popup. @@ -1352,6 +1396,15 @@ impl TuiApp { } } + // Signature hint popup (above input, near where the function name starts) + if let Some((func_name, sigs)) = &self.signature_hint { + if self.history_search.is_none() && self.fuzzy_state.is_none() { + let func_name = func_name.clone(); + let sigs = sigs.clone(); + Self::render_signature_hint(f, layout.input, area, &func_name, &sigs); + } + } + // Fuzzy search overlay (rendered on top of everything) if let Some(fs) = &self.fuzzy_state { let fuzzy_rect = fuzzy_popup::popup_area(layout.input, area); @@ -1575,6 +1628,69 @@ impl TuiApp { f.render_widget(List::new(items), chunks[0]); } + fn render_signature_hint( + f: &mut ratatui::Frame, + input_area: Rect, + total: Rect, + func_name: &str, + sigs: &[String], + ) { + use ratatui::{ + style::Modifier, + widgets::{Clear, List, ListItem}, + }; + + // Build display lines + let lines: Vec = if sigs.is_empty() { + vec![format!("{}(...)", func_name)] + } else { + sigs.to_vec() + }; + + let n = lines.len() as u16; + let popup_h = n + 2; // content rows + 2 borders + let popup_w = { + let max_w = lines.iter().map(|l| l.len()).max().unwrap_or(10) as u16 + 4; + max_w.min(total.width.saturating_sub(4)) + }; + + // Anchor: just above the input area, right-aligned within the terminal + let y = input_area.y.saturating_sub(popup_h); + let x = total.width.saturating_sub(popup_w + 1); + let rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, rect); + + let title = Span::styled( + format!(" {} ", func_name), + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + ); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Indexed(67))); + + let inner = block.inner(rect); + f.render_widget(block, rect); + + let items: Vec = lines + .iter() + .map(|sig| { + // Colour: function name in cyan, parens+types in default + let paren = sig.find('(').unwrap_or(sig.len()); + let (fname, rest) = sig.split_at(paren); + ListItem::new(Line::from(vec![ + Span::styled(fname.to_string(), Style::default().fg(Color::LightCyan)), + Span::styled(rest.to_string(), Style::default().fg(Color::White)), + ])) + }) + .collect(); + + f.render_widget(List::new(items), inner); + } + fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { use ratatui::{ style::Modifier, diff --git a/src/tui/signature_hint.rs b/src/tui/signature_hint.rs new file mode 100644 index 0000000..1c72c6a --- /dev/null +++ b/src/tui/signature_hint.rs @@ -0,0 +1,243 @@ +/// Detect whether the cursor is currently inside a SQL function call. +/// +/// Walks the SQL text up to the cursor position, tracking open/close +/// parentheses with a stack. Each `(` pushed records the identifier +/// immediately before it (the function name) or `None` for grouping +/// expressions. Returns the innermost function name on the stack, if any. + +/// Walk `sql[..cursor]` and return the name of the innermost function call +/// the cursor is currently inside, or `None`. +pub fn detect_function_at_cursor(sql: &str, cursor: usize) -> Option { + let bytes = sql.as_bytes(); + let end = cursor.min(bytes.len()); + // Stack of Option: Some(name) for function calls, None for + // plain grouping parens like `(1 + 2)`. + let mut stack: Vec> = Vec::new(); + let mut i = 0; + + while i < end { + match bytes[i] { + // Single-quoted string + b'\'' => { + i += 1; + while i < end { + match bytes[i] { + b'\'' => { i += 1; break; } + b'\\' => i += 2, + _ => i += 1, + } + } + } + // Dollar-quoted string $$...$$ + b'$' if i + 1 < end && bytes[i + 1] == b'$' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'$' && bytes[i + 1] == b'$' { i += 2; break; } + i += 1; + } + } + // Double-quoted identifier + b'"' => { + i += 1; + while i < end && bytes[i] != b'"' { i += 1; } + if i < end { i += 1; } + } + // Line comment + b'-' if i + 1 < end && bytes[i + 1] == b'-' => { + while i < end && bytes[i] != b'\n' { i += 1; } + } + // Block comment + b'/' if i + 1 < end && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { i += 2; break; } + i += 1; + } + } + b'(' => { + let name = ident_before(sql, i); + stack.push(name); + i += 1; + } + b')' => { + stack.pop(); + i += 1; + } + _ => i += 1, + } + } + + // Innermost function name (ignore grouping-paren Nones) + stack.into_iter().rev().find_map(|x| x) +} + +/// The byte offset in `sql` where the currently-active function name starts, +/// or `None`. Used to position the popup horizontally. +pub fn func_name_start(sql: &str, cursor: usize) -> Option { + let bytes = sql.as_bytes(); + let end = cursor.min(bytes.len()); + let mut stack: Vec> = Vec::new(); // (name_start, name) + let mut i = 0; + + while i < end { + match bytes[i] { + b'\'' => { + i += 1; + while i < end { + match bytes[i] { + b'\'' => { i += 1; break; } + b'\\' => i += 2, + _ => i += 1, + } + } + } + b'$' if i + 1 < end && bytes[i + 1] == b'$' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'$' && bytes[i + 1] == b'$' { i += 2; break; } + i += 1; + } + } + b'"' => { + i += 1; + while i < end && bytes[i] != b'"' { i += 1; } + if i < end { i += 1; } + } + b'-' if i + 1 < end && bytes[i + 1] == b'-' => { + while i < end && bytes[i] != b'\n' { i += 1; } + } + b'/' if i + 1 < end && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { i += 2; break; } + i += 1; + } + } + b'(' => { + let entry = ident_before_with_start(sql, i); + stack.push(entry); + i += 1; + } + b')' => { stack.pop(); i += 1; } + _ => i += 1, + } + } + + stack.into_iter().rev().find_map(|x| x).map(|(start, _)| start) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Extract the SQL identifier that ends immediately before byte position `pos` +/// (trimming trailing whitespace), lowercased. Returns `None` for keywords +/// that open a sub-expression (`CASE`, `ARRAY`, …) or when nothing is found. +fn ident_before(sql: &str, pos: usize) -> Option { + ident_before_with_start(sql, pos).map(|(_, name)| name) +} + +fn ident_before_with_start(sql: &str, pos: usize) -> Option<(usize, String)> { + let before = &sql[..pos]; + let trimmed = before.trim_end(); + if trimmed.is_empty() { return None; } + + let end_byte = trimmed.len(); + // Walk backwards through the trimmed slice while chars are ident chars + let mut start_byte = end_byte; + for (byte_idx, ch) in trimmed.char_indices().rev() { + if ch.is_alphanumeric() || ch == '_' { + start_byte = byte_idx; + } else { + break; + } + } + + let name = &trimmed[start_byte..end_byte]; + if name.is_empty() || name.chars().next()?.is_ascii_digit() { + return None; + } + + // Filter out SQL keywords that look like identifiers but aren't function calls. + // These are reserved words that can appear before `(` in non-call contexts. + let upper = name.to_uppercase(); + if matches!( + upper.as_str(), + "SELECT" | "FROM" | "WHERE" | "JOIN" | "INNER" | "LEFT" | "RIGHT" | "FULL" + | "OUTER" | "CROSS" | "ON" | "USING" | "GROUP" | "ORDER" | "HAVING" + | "LIMIT" | "OFFSET" | "UNION" | "INTERSECT" | "EXCEPT" | "WITH" + | "INSERT" | "INTO" | "UPDATE" | "SET" | "DELETE" | "CREATE" | "DROP" + | "ALTER" | "TABLE" | "VIEW" | "INDEX" | "DATABASE" | "SCHEMA" + | "CASE" | "WHEN" | "THEN" | "ELSE" | "END" + | "IN" | "NOT" | "AND" | "OR" | "BETWEEN" | "LIKE" | "ILIKE" + | "IS" | "AS" | "BY" | "AT" | "DISTINCT" | "ALL" | "EXISTS" + | "PRIMARY" | "FOREIGN" | "KEY" | "REFERENCES" | "UNIQUE" | "CHECK" + | "DEFAULT" | "CONSTRAINT" | "CASCADE" | "RESTRICT" + | "BEGIN" | "COMMIT" | "ROLLBACK" | "TRANSACTION" + | "EXPLAIN" | "DESCRIBE" | "SHOW" | "USE" + ) { + return None; + } + + Some((start_byte, name.to_lowercase())) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inside_simple_call() { + assert_eq!( + detect_function_at_cursor("SELECT upper(", 13), + Some("upper".to_string()) + ); + } + + #[test] + fn outside_after_close() { + assert_eq!(detect_function_at_cursor("SELECT upper(x)", 15), None); + } + + #[test] + fn nested_call() { + // cursor inside inner call: coalesce(upper( + let sql = "SELECT coalesce(upper("; + assert_eq!( + detect_function_at_cursor(sql, sql.len()), + Some("upper".to_string()) + ); + } + + #[test] + fn between_calls() { + // cursor after closing inner `)` but inside outer + let sql = "SELECT coalesce(upper(x), "; + assert_eq!( + detect_function_at_cursor(sql, sql.len()), + Some("coalesce".to_string()) + ); + } + + #[test] + fn grouping_paren_ignored() { + // `(1 + 2)` — no function name before `(` + assert_eq!(detect_function_at_cursor("SELECT (1 + 2", 13), None); + } + + #[test] + fn inside_string_not_detected() { + // The `(` is inside a string literal + let sql = "SELECT 'upper("; + assert_eq!(detect_function_at_cursor(sql, sql.len()), None); + } + + #[test] + fn multiline_sql() { + let sql = "SELECT\n upper(\n col"; + assert_eq!( + detect_function_at_cursor(sql, sql.len()), + Some("upper".to_string()) + ); + } +} From a7fdd918cf72c15ea7bb5ec1e542e0180a4f6bac Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 13:58:26 +0100 Subject: [PATCH 068/147] Implement 7 CLI improvements - Replace all backslash commands with forward slash (/help, /set, /unset, /benchmark, /export, /view, /refresh) - Print custom settings (from /set) in grey between query echo and result - Add Firebolt-Machine-Query: true header to internal schema queries - Tab completion for functions no longer inserts ( when cursor is already before ( - Handle -distinct function pattern: completes to name(DISTINCT - Add /qh [limit] [since_minutes] command alias for query history lookup, with extensible expand_command_alias() function for future aliases - Benchmark mode automatically adds enable_result_cache=false for accurate timing Co-Authored-By: Claude Sonnet 4.6 --- src/completion/candidates.rs | 23 +++++- src/meta_commands.rs | 64 ++++++++-------- src/query.rs | 9 ++- src/tui/mod.rs | 145 ++++++++++++++++++++++++++++------- 4 files changed, 172 insertions(+), 69 deletions(-) diff --git a/src/completion/candidates.rs b/src/completion/candidates.rs index b5553d3..2e61eb5 100644 --- a/src/completion/candidates.rs +++ b/src/completion/candidates.rs @@ -262,15 +262,30 @@ pub fn collect_candidates( let usage_count = usage_tracker.get_count(ItemType::Function, &function); let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + // Handle the "name-distinct" pattern: e.g. "count-distinct" → + // display as "count-distinct()" but insert "count(DISTINCT ". + let (display, insert, alts) = if let Some(base) = function.strip_suffix("-distinct") { + ( + format!("{}-distinct()", base), + format!("{}(DISTINCT ", base), + vec![function.clone(), base.to_string(), format!("{}_distinct", base)], + ) + } else { + ( + format!("{}()", function), + format!("{}(", function), + vec![function.clone()], + ) + }; + items.push(Candidate { - display: format!("{}()", function), - insert: format!("{}(", function), + display, + insert, description: "function", item_type: ItemType::Function, schema: String::new(), table_name: None, - // Match without parens in case user types "func_name" without "(" - alts: vec![function.clone()], + alts, priority: PRIORITY_FUNCTION.saturating_add(usage_bonus), }); } diff --git a/src/meta_commands.rs b/src/meta_commands.rs index bcaaaaa..dba0904 100644 --- a/src/meta_commands.rs +++ b/src/meta_commands.rs @@ -2,39 +2,39 @@ use crate::context::Context; use once_cell::sync::Lazy; use regex::Regex; -// Handle meta-commands (backslash commands) +// Handle meta-commands (slash commands) pub fn handle_meta_command(context: &mut Context, command: &str) -> Result> { - // Handle \set PROMPT1 command + // Handle /set PROMPT1 command if let Some(prompt) = parse_set_prompt(command, "PROMPT1") { context.set_prompt1(prompt); return Ok(true); } - // Handle \set PROMPT2 command + // Handle /set PROMPT2 command if let Some(prompt) = parse_set_prompt(command, "PROMPT2") { context.set_prompt2(prompt); return Ok(true); } - // Handle \set PROMPT3 command + // Handle /set PROMPT3 command if let Some(prompt) = parse_set_prompt(command, "PROMPT3") { context.set_prompt3(prompt); return Ok(true); } - // Handle \unset PROMPT1 command + // Handle /unset PROMPT1 command if parse_unset_prompt(command, "PROMPT1") { context.prompt1 = None; return Ok(true); } - // Handle \unset PROMPT2 command + // Handle /unset PROMPT2 command if parse_unset_prompt(command, "PROMPT2") { context.prompt2 = None; return Ok(true); } - // Handle \unset PROMPT3 command + // Handle /unset PROMPT3 command if parse_unset_prompt(command, "PROMPT3") { context.prompt3 = None; return Ok(true); @@ -43,9 +43,9 @@ pub fn handle_meta_command(context: &mut Context, command: &str) -> Result Option { - static SET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*\\set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap()); + static SET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*/set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap()); if let Some(captures) = SET_PROMPT_RE.captures(command) { // Check if the prompt type matches @@ -66,9 +66,9 @@ fn parse_set_prompt(command: &str, prompt_type: &str) -> Option { None } -// Generic function to parse \unset PROMPT command +// Generic function to parse /unset PROMPT command fn parse_unset_prompt(command: &str, prompt_type: &str) -> bool { - static UNSET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*\\unset\s+(\w+)\s*$"#).unwrap()); + static UNSET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*/unset\s+(\w+)\s*$"#).unwrap()); if let Some(captures) = UNSET_PROMPT_RE.captures(command) { if let Some(cmd_prompt_type) = captures.get(1) { @@ -89,7 +89,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT1 'custom_prompt> '"#; + let command = r#"/set PROMPT1 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("custom_prompt> ".to_string())); @@ -100,7 +100,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT1 "custom_prompt> ""#; + let command = r#"/set PROMPT1 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("custom_prompt> ".to_string())); @@ -111,7 +111,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT1 custom_prompt>"#; + let command = r#"/set PROMPT1 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("custom_prompt>".to_string())); @@ -122,7 +122,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT2 'custom_prompt> '"#; + let command = r#"/set PROMPT2 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, Some("custom_prompt> ".to_string())); @@ -133,7 +133,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT2 "custom_prompt> ""#; + let command = r#"/set PROMPT2 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, Some("custom_prompt> ".to_string())); @@ -144,7 +144,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT2 custom_prompt>"#; + let command = r#"/set PROMPT2 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, Some("custom_prompt>".to_string())); @@ -155,7 +155,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT3 'custom_prompt> '"#; + let command = r#"/set PROMPT3 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, Some("custom_prompt> ".to_string())); @@ -166,7 +166,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT3 "custom_prompt> ""#; + let command = r#"/set PROMPT3 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, Some("custom_prompt> ".to_string())); @@ -177,7 +177,7 @@ mod tests { let args = get_args().unwrap(); let mut context = Context::new(args); - let command = r#"\set PROMPT3 custom_prompt>"#; + let command = r#"/set PROMPT3 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, Some("custom_prompt>".to_string())); @@ -193,7 +193,7 @@ mod tests { assert_eq!(context.prompt1, Some("test> ".to_string())); // Then unset it - let command = r#"\unset PROMPT1"#; + let command = r#"/unset PROMPT1"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, None); @@ -209,7 +209,7 @@ mod tests { assert_eq!(context.prompt2, Some("test> ".to_string())); // Then unset it - let command = r#"\unset PROMPT2"#; + let command = r#"/unset PROMPT2"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, None); @@ -225,7 +225,7 @@ mod tests { assert_eq!(context.prompt3, Some("test> ".to_string())); // Then unset it - let command = r#"\unset PROMPT3"#; + let command = r#"/unset PROMPT3"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, None); @@ -241,7 +241,7 @@ mod tests { let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); - let command = r#"\set INVALID value"#; + let command = r#"/set INVALID value"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); } @@ -252,7 +252,7 @@ mod tests { let mut context = Context::new(args); // Test with various whitespace - let command = r#" \set PROMPT1 'test>' "#; + let command = r#" /set PROMPT1 'test>' "#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("test>".to_string())); @@ -264,9 +264,9 @@ mod tests { let mut context = Context::new(args); // Set all three prompts to different values - let command1 = r#"\set PROMPT1 'prompt1> '"#; - let command2 = r#"\set PROMPT2 'prompt2> '"#; - let command3 = r#"\set PROMPT3 'prompt3> '"#; + let command1 = r#"/set PROMPT1 'prompt1> '"#; + let command2 = r#"/set PROMPT2 'prompt2> '"#; + let command3 = r#"/set PROMPT3 'prompt3> '"#; handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); @@ -278,7 +278,7 @@ mod tests { assert_eq!(context.prompt3, Some("prompt3> ".to_string())); // Unset only PROMPT2 - let unset_command = r#"\unset PROMPT2"#; + let unset_command = r#"/unset PROMPT2"#; handle_meta_command(&mut context, unset_command).unwrap(); // Verify only PROMPT2 was unset @@ -293,9 +293,9 @@ mod tests { let mut context = Context::new(args); // Test case insensitive prompt type matching - let command1 = r#"\set prompt1 'test1> '"#; - let command2 = r#"\set Prompt2 'test2> '"#; - let command3 = r#"\set PROMPT3 'test3> '"#; + let command1 = r#"/set prompt1 'test1> '"#; + let command2 = r#"/set Prompt2 'test2> '"#; + let command3 = r#"/set PROMPT3 'test3> '"#; handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); diff --git a/src/query.rs b/src/query.rs index e12f953..6cdfd0b 100644 --- a/src/query.rs +++ b/src/query.rs @@ -82,7 +82,7 @@ fn apply_output_limits(rows: &[Vec]) -> (&[Vec]) -> (&[Vec Result Result<(), Box< let rendered = render_table_output(context, &columns, &display_rows, terminal_width, max_cell); out!(context, "{}", rendered); } - out_err!(context, "Showing first {} rows — collecting remainder for Ctrl+V / \\view...", + out_err!(context, "Showing first {} rows — collecting remainder for Ctrl+V / /view...", format_number(display_rows.len() as u64)); display_emitted = true; } else { @@ -517,7 +518,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } } else { // Partial display was already emitted; show the final total - out_err!(context, "Showing {} of {} rows (press Ctrl+V or \\view to see all).", + out_err!(context, "Showing {} of {} rows (press Ctrl+V or /view to see all).", format_number(display_rows.len() as u64), format_number(all_rows.len() as u64)); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7f17eb6..d9cf518 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -52,11 +52,11 @@ use output_pane::OutputPane; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -/// Parse `\benchmark [N] ` arguments. +/// Parse `/benchmark [N] ` arguments. /// Returns `(n_runs, query_text)` where `n_runs` defaults to 3. fn parse_benchmark_args(cmd: &str) -> (usize, String) { let rest = cmd - .strip_prefix("\\benchmark") + .strip_prefix("/benchmark") .unwrap_or(cmd) .trim() .to_string(); @@ -74,6 +74,40 @@ fn parse_benchmark_args(cmd: &str) -> (usize, String) { (3, rest) } +/// Expand a command alias to a full SQL query, returning `None` if the command +/// is not a known alias. +/// +/// Adding a new alias: add a branch to the `match` below. +fn expand_command_alias(cmd: &str) -> Option { + let parts: Vec<&str> = cmd.splitn(3, char::is_whitespace).collect(); + match parts[0] { + "/qh" => { + // /qh [limit] [since_minutes] + let limit = parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(10); + let since_minutes = parts.get(2).and_then(|s| s.parse::().ok()).unwrap_or(60); + Some(format!( + "SELECT * FROM information_schema.engine_user_query_history \ + WHERE start_time > now() - interval '{since_minutes} minutes' \ + ORDER BY start_time DESC LIMIT {limit}" + )) + } + _ => None, + } +} + +/// Percent-decode a `key=value` setting string for display. +/// Splits on the first `=` so only the value part is decoded. +fn url_decode_setting(s: &str) -> String { + if let Some((key, val)) = s.split_once('=') { + let decoded = urlencoding::decode(val) + .map(|v| v.into_owned()) + .unwrap_or_else(|_| val.to_string()); + format!("{}={}", key, decoded) + } else { + s.to_string() + } +} + fn format_with_commas(n: u64) -> String { let s = n.to_string(); let mut result = String::with_capacity(s.len() + s.len() / 3); @@ -463,9 +497,9 @@ impl TuiApp { return false; } - // Backslash meta-commands - if trimmed.starts_with('\\') { - self.handle_backslash_command(&trimmed).await; + // Slash meta-commands + if trimmed.starts_with('/') { + self.handle_slash_command(&trimmed).await; self.reset_textarea(); return false; } @@ -701,13 +735,28 @@ impl TuiApp { let line_to_cursor = &lines[cursor_row][..cursor_col]; let full_sql = lines.join("\n"); - let (word_start_byte_in_line, items) = + let (word_start_byte_in_line, mut items) = self.completer.complete_at(line_to_cursor, cursor_col, &full_sql); if items.is_empty() { return; } + // If the character immediately after the cursor is already `(`, strip the + // trailing `(` from function completion inserts so we don't double up. + let next_char_is_paren = lines + .get(cursor_row) + .and_then(|line| line[cursor_col..].chars().next()) + .map(|c| c == '(') + .unwrap_or(false); + if next_char_is_paren { + for item in &mut items { + if item.value.ends_with('(') { + item.value.pop(); + } + } + } + // word_start as byte offset in the FULL text (for deletion later) let word_start_byte = byte_offset - (cursor_col - word_start_byte_in_line); @@ -866,28 +915,40 @@ impl TuiApp { false } - // ── Backslash commands ─────────────────────────────────────────────────── + // ── Slash commands ──────────────────────────────────────────────────────── - async fn handle_backslash_command(&mut self, cmd: &str) { - // Dispatch \export: write last result to a file - if cmd.starts_with("\\export") { + async fn handle_slash_command(&mut self, cmd: &str) { + // Dispatch /export: write last result to a file + if cmd.starts_with("/export") { self.history.add(cmd.to_string()); self.do_export(cmd); return; } - // Dispatch \benchmark separately since it needs async query execution - if cmd.starts_with("\\benchmark") { + // Dispatch /benchmark separately since it needs async query execution + if cmd.starts_with("/benchmark") { self.history.add(cmd.to_string()); let (n_runs, query_text) = parse_benchmark_args(cmd); self.do_benchmark(n_runs, query_text).await; return; } + // Dispatch command aliases (e.g. /qh) + if let Some(expanded) = expand_command_alias(cmd) { + // Execute the expanded SQL query as if the user typed it + if let Some(queries) = crate::query::try_split_queries(&expanded) { + self.history.add(cmd.to_string()); + self.execute_queries(expanded, queries).await; + } else { + self.output.push_line(format!("Error: could not parse expanded query for: {}", cmd)); + } + return; + } + match cmd { - "\\view" => self.open_viewer(), - "\\refresh" | "\\refresh_cache" => self.do_refresh(), - "\\help" => self.show_help(), + "/view" => self.open_viewer(), + "/refresh" | "/refresh_cache" => self.do_refresh(), + "/help" => self.show_help(), _ => { match handle_meta_command(&mut self.context, cmd) { Ok(true) => {} @@ -903,13 +964,13 @@ impl TuiApp { // ── Export ─────────────────────────────────────────────────────────────── - /// Handle `\export [format]` — write the last query result to a file. + /// Handle `/export [format]` — write the last query result to a file. /// Supported formats: csv (default), tsv, json, jsonlines. fn do_export(&mut self, cmd: &str) { - // Parse: \export [format] - let rest = cmd.strip_prefix("\\export").unwrap_or("").trim(); + // Parse: /export [format] + let rest = cmd.strip_prefix("/export").unwrap_or("").trim(); if rest.is_empty() { - self.output.push_line("Usage: \\export [csv|tsv|json|jsonlines]"); + self.output.push_line("Usage: /export [csv|tsv|json|jsonlines]"); return; } @@ -1024,7 +1085,7 @@ impl TuiApp { async fn do_benchmark(&mut self, n_runs: usize, query_text: String) { if query_text.trim().is_empty() { self.output.push_line( - "Usage: \\benchmark [N] (default N=3, first run is warmup)", + "Usage: /benchmark [N] (default N=3, first run is warmup)", ); return; } @@ -1052,6 +1113,11 @@ impl TuiApp { // Silence all output during benchmark runs let (tx, mut rx) = mpsc::unbounded_channel::(); let mut ctx = self.context.clone(); + // Disable result cache for accurate timing + if !ctx.args.extra.iter().any(|e| e.starts_with("enable_result_cache=")) { + ctx.args.extra.push("enable_result_cache=false".to_string()); + ctx.update_url(); + } ctx.tui_output_tx = Some(tx); let start = Instant::now(); @@ -1241,6 +1307,26 @@ impl TuiApp { // Echo query to output pane with syntax highlighting self.push_sql_echo(original_text.trim()); + // Show custom settings (from /set commands) between query echo and result + let display_extras: Vec = self + .context + .args + .extra + .iter() + .filter(|e| { + // Skip params that are always present as part of the URL construction + !e.starts_with("database=") + && !e.starts_with("format=") + && !e.starts_with("query_label=") + && !e.starts_with("advanced_mode=") + && !e.starts_with("output_format=") + }) + .map(|e| url_decode_setting(e)) + .collect(); + if !display_extras.is_empty() { + self.output.push_stat(&format!(" Settings: {}", display_extras.join(", "))); + } + let (tx, rx) = mpsc::unbounded_channel::(); let cancel_token = CancellationToken::new(); @@ -1721,11 +1807,12 @@ impl TuiApp { ("Escape", "Close any open popup"), ]; let commands: &[(&str, &str)] = &[ - ("\\help", "Show this help"), - ("\\set k=v", "Set a query parameter"), - ("\\unset k", "Remove a query parameter"), - ("\\benchmark [N]", "Run query N times (default 3) and report timings"), - ("\\export ", "Export last result to CSV/JSON/TSV"), + ("/help", "Show this help"), + ("/set k=v", "Set a query parameter"), + ("/unset k", "Remove a query parameter"), + ("/benchmark [N]", "Run query N times (default 3) and report timings"), + ("/export ", "Export last result to CSV/JSON/TSV"), + ("/qh [limit] [min]", "Query history (default: last 10 in 60 min)"), ]; // Determine column widths @@ -1871,21 +1958,21 @@ mod tests { #[test] fn test_parse_benchmark_args_default() { - let (n, q) = parse_benchmark_args("\\benchmark SELECT 1"); + let (n, q) = parse_benchmark_args("/benchmark SELECT 1"); assert_eq!(n, 3); assert_eq!(q, "SELECT 1"); } #[test] fn test_parse_benchmark_args_explicit_n() { - let (n, q) = parse_benchmark_args("\\benchmark 5 SELECT 1"); + let (n, q) = parse_benchmark_args("/benchmark 5 SELECT 1"); assert_eq!(n, 5); assert_eq!(q, "SELECT 1"); } #[test] fn test_parse_benchmark_args_no_query() { - let (n, q) = parse_benchmark_args("\\benchmark"); + let (n, q) = parse_benchmark_args("/benchmark"); assert_eq!(n, 3); assert_eq!(q, ""); } @@ -1893,7 +1980,7 @@ mod tests { #[test] fn test_parse_benchmark_args_min_n() { // 0 should be clamped to 1 - let (n, _) = parse_benchmark_args("\\benchmark 0 SELECT 1"); + let (n, _) = parse_benchmark_args("/benchmark 0 SELECT 1"); assert_eq!(n, 1); } From b93971dd4b9f1c339b9660042700bcb115f72fe3 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 14:06:04 +0100 Subject: [PATCH 069/147] Remove /export command Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 122 ------------------------------------------------- 1 file changed, 122 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d9cf518..5773b96 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -918,13 +918,6 @@ impl TuiApp { // ── Slash commands ──────────────────────────────────────────────────────── async fn handle_slash_command(&mut self, cmd: &str) { - // Dispatch /export: write last result to a file - if cmd.starts_with("/export") { - self.history.add(cmd.to_string()); - self.do_export(cmd); - return; - } - // Dispatch /benchmark separately since it needs async query execution if cmd.starts_with("/benchmark") { self.history.add(cmd.to_string()); @@ -964,119 +957,6 @@ impl TuiApp { // ── Export ─────────────────────────────────────────────────────────────── - /// Handle `/export [format]` — write the last query result to a file. - /// Supported formats: csv (default), tsv, json, jsonlines. - fn do_export(&mut self, cmd: &str) { - // Parse: /export [format] - let rest = cmd.strip_prefix("/export").unwrap_or("").trim(); - if rest.is_empty() { - self.output.push_line("Usage: /export [csv|tsv|json|jsonlines]"); - return; - } - - let mut parts = rest.splitn(2, char::is_whitespace); - let path_str = parts.next().unwrap_or("").trim().to_string(); - let format = parts - .next() - .map(|s| s.trim().to_lowercase()) - .unwrap_or_else(|| "csv".to_string()); - - let result = match &self.context.last_result { - Some(r) => r.clone(), - None => { - self.set_flash("No results to export — run a query first"); - return; - } - }; - - use crate::table_renderer::write_result_as_csv; - use std::fs::File; - use std::io::{BufWriter, Write}; - - let file = match File::create(&path_str) { - Ok(f) => f, - Err(e) => { - self.output - .push_line(format!("Error: could not create file '{}': {}", path_str, e)); - return; - } - }; - let mut writer = BufWriter::new(file); - - // Helper: format a JSON value as a plain string for TSV output - fn val_to_str(v: &serde_json::Value) -> String { - match v { - serde_json::Value::Null => String::new(), - serde_json::Value::String(s) => s.clone(), - other => other.to_string(), - } - } - - let write_result = match format.as_str() { - "tsv" => { - // Tab-separated with header - let header: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); - let _ = writeln!(writer, "{}", header.join("\t")); - for row in &result.rows { - let values: Vec = row.iter().map(val_to_str).collect(); - let _ = writeln!(writer, "{}", values.join("\t")); - } - writer.flush() - } - "json" => { - // JSON array of objects - let rows_json: Vec = result - .rows - .iter() - .map(|row| { - let obj: serde_json::Map = result - .columns - .iter() - .zip(row.iter()) - .map(|(col, val)| (col.name.clone(), val.clone())) - .collect(); - serde_json::Value::Object(obj) - }) - .collect(); - let _ = writeln!(writer, "{}", serde_json::to_string_pretty(&rows_json).unwrap_or_default()); - writer.flush() - } - "jsonlines" | "jsonl" | "ndjson" => { - // One JSON object per line - for row in &result.rows { - let obj: serde_json::Map = result - .columns - .iter() - .zip(row.iter()) - .map(|(col, val)| (col.name.clone(), val.clone())) - .collect(); - let _ = writeln!(writer, "{}", serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()); - } - writer.flush() - } - _ => { - // Default: CSV - write_result_as_csv(&mut writer, &result.columns, &result.rows).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, e.to_string()) - }) - } - }; - - match write_result { - Ok(()) => { - self.output.push_line(format!( - "Exported {} rows to '{}' ({} format)", - result.rows.len(), - path_str, - format - )); - } - Err(e) => { - self.output - .push_line(format!("Error writing to '{}': {}", path_str, e)); - } - } - } // ── Benchmark ──────────────────────────────────────────────────────────── @@ -1262,7 +1142,6 @@ impl TuiApp { Special commands:\n\ \\refresh - Manually refresh schema cache\n\ \\benchmark [N] - Run query N+1 times (1 warmup), show timing stats\n\ - \\export [fmt] - Export last result to file (csv/tsv/json/jsonlines)\n\ \\help - Show this help message\n\ \n\ SQL-style commands:\n\ @@ -1811,7 +1690,6 @@ impl TuiApp { ("/set k=v", "Set a query parameter"), ("/unset k", "Remove a query parameter"), ("/benchmark [N]", "Run query N times (default 3) and report timings"), - ("/export ", "Export last result to CSV/JSON/TSV"), ("/qh [limit] [min]", "Query history (default: last 10 in 60 min)"), ]; From 772005dd29159474c48112758e1c0a7f74cdd06c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 14:17:37 +0100 Subject: [PATCH 070/147] Move cursor inside existing ( after function completion; /qh default limit 100 When a function name is tab-completed and the next character is already `(`, the cursor now moves past it so it lands inside the argument list. /qh default limit changed from 10 to 100. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/completion_popup.rs | 5 +++++ src/tui/mod.rs | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index 7dc31e0..efcf1b9 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -26,6 +26,9 @@ pub struct CompletionState { pub word_start_col: usize, /// Textarea cursor row (0-based) when completion was triggered. pub cursor_row: usize, + /// When true, move the cursor one step forward after insertion to place it + /// inside an already-existing `(` that follows the completed word. + pub advance_past_paren: bool, } impl CompletionState { @@ -34,6 +37,7 @@ impl CompletionState { word_start_byte: usize, word_start_col: usize, cursor_row: usize, + advance_past_paren: bool, ) -> Self { Self { items, @@ -42,6 +46,7 @@ impl CompletionState { word_start_byte, word_start_col, cursor_row, + advance_past_paren, } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5773b96..c66051c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -83,7 +83,7 @@ fn expand_command_alias(cmd: &str) -> Option { match parts[0] { "/qh" => { // /qh [limit] [since_minutes] - let limit = parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(10); + let limit = parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(100); let since_minutes = parts.get(2).and_then(|s| s.parse::().ok()).unwrap_or(60); Some(format!( "SELECT * FROM information_schema.engine_user_query_history \ @@ -770,10 +770,13 @@ impl TuiApp { self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, word_start_byte_in_line as u16)); self.textarea.delete_str(partial_len); self.textarea.insert_str(&value); + if next_char_is_paren { + self.textarea.move_cursor(CursorMove::Forward); + } return; } - let cs = CompletionState::new(items, word_start_byte, word_start_byte_in_line, cursor_row); + let cs = CompletionState::new(items, word_start_byte, word_start_byte_in_line, cursor_row, next_char_is_paren); self.completion_state = Some(cs); } @@ -791,11 +794,15 @@ impl TuiApp { let (cursor_row, cursor_col) = self.textarea.cursor(); let partial_len = cursor_col - cs.word_start_col; + let advance = cs.advance_past_paren; // Jump to word start, then delete the partial word forward, then insert. // (delete_str deletes FORWARD from cursor, so we must reposition first.) self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, cs.word_start_col as u16)); self.textarea.delete_str(partial_len); self.textarea.insert_str(&selected); + if advance { + self.textarea.move_cursor(CursorMove::Forward); + } } /// Key handler active while the completion popup is open. @@ -1690,7 +1697,7 @@ impl TuiApp { ("/set k=v", "Set a query parameter"), ("/unset k", "Remove a query parameter"), ("/benchmark [N]", "Run query N times (default 3) and report timings"), - ("/qh [limit] [min]", "Query history (default: last 10 in 60 min)"), + ("/qh [limit] [min]", "Query history (default: last 100 in 60 min)"), ]; // Determine column widths From adc7275b5bc4cefdbfdf2880cc08f0b1a98cfd15 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 23 Feb 2026 19:09:11 +0100 Subject: [PATCH 071/147] Benchmark: live spinner/timer, per-run output, Ctrl+C cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite do_benchmark to spawn a background task using the same channel + is_running pattern as execute_queries. This gives: - Spinner and elapsed timer visible during each run - Each run result (warmup/run N/M: Xms) streamed to output as it finishes - Running pane hint shows current run ("warmup…", "run 1/3…") - Ctrl+C cancels via the shared CancellationToken Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 175 +++++++++++++++++++++++++++++-------------------- src/tui_msg.rs | 2 + 2 files changed, 106 insertions(+), 71 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c66051c..ab696e7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -332,6 +332,9 @@ impl TuiApp { loop { match rx.try_recv() { + Ok(TuiMsg::RunHint(hint)) => { + self.running_hint = hint; + } Ok(TuiMsg::Progress(n)) => { self.progress_rows = n; } @@ -969,6 +972,10 @@ impl TuiApp { /// Run `query_text` N+1 times (1 warmup), collect per-run elapsed times, /// and display min/avg/p90/max statistics in the output pane. + /// + /// Spawns a background task and sets `is_running = true` so the spinner / + /// timer are visible and Ctrl+C works. Each run's result is streamed back + /// through the normal query channel as it completes. async fn do_benchmark(&mut self, n_runs: usize, query_text: String) { if query_text.trim().is_empty() { self.output.push_line( @@ -986,89 +993,115 @@ impl TuiApp { )); self.push_sql_echo(query_text.trim()); - let mut times_ms: Vec = Vec::with_capacity(n_runs); + // Set up channel + running state, exactly like execute_queries. + let (tx, rx) = mpsc::unbounded_channel::(); + let cancel_token = CancellationToken::new(); + self.query_rx = Some(rx); + self.cancel_token = Some(cancel_token.clone()); + self.is_running = true; + self.running_hint.clear(); + self.progress_rows = 0; + self.query_start = Some(Instant::now()); - for i in 0..total { - let queries = match crate::query::try_split_queries(query_text.trim()) { - Some(q) => q, - None => { - self.output.push_line("Error: could not parse query"); - return; - } - }; + let query_text = query_text.trim().to_string(); + let mut ctx = self.context.clone(); + // Disable result cache for accurate timing + if !ctx.args.extra.iter().any(|e| e.starts_with("enable_result_cache=")) { + ctx.args.extra.push("enable_result_cache=false".to_string()); + ctx.update_url(); + } - // Silence all output during benchmark runs - let (tx, mut rx) = mpsc::unbounded_channel::(); - let mut ctx = self.context.clone(); - // Disable result cache for accurate timing - if !ctx.args.extra.iter().any(|e| e.starts_with("enable_result_cache=")) { - ctx.args.extra.push("enable_result_cache=false".to_string()); - ctx.update_url(); - } - ctx.tui_output_tx = Some(tx); + tokio::spawn(async move { + let mut times_ms: Vec = Vec::with_capacity(n_runs); - let start = Instant::now(); - let handle = tokio::spawn(async move { - for q in queries { - if crate::query::query(&mut ctx, q).await.is_err() { - return false; - } + for i in 0..total { + if cancel_token.is_cancelled() { + let _ = tx.send(TuiMsg::Line("Benchmark cancelled.".to_string())); + return; } - true - }); - // Drain messages (discard) while the query runs - loop { - tokio::select! { - msg = rx.recv() => { - if msg.is_none() { break; } + let label = if i == 0 { + "warmup".to_string() + } else { + format!("run {}/{}", i, n_runs) + }; + let _ = tx.send(TuiMsg::RunHint(format!(" {label}…"))); + + let queries = match crate::query::try_split_queries(&query_text) { + Some(q) => q, + None => { + let _ = tx.send(TuiMsg::Line("Error: could not parse query".to_string())); + return; } - _ = tokio::time::sleep(Duration::from_millis(10)) => { - // Continue draining + }; + + // Inner context: discard output, share the cancel token + let (run_tx, mut run_rx) = mpsc::unbounded_channel::(); + let mut run_ctx = ctx.clone(); + run_ctx.tui_output_tx = Some(run_tx); + run_ctx.query_cancel = Some(cancel_token.clone()); + + let start = Instant::now(); + let mut handle = tokio::spawn(async move { + for q in queries { + if crate::query::query(&mut run_ctx, q).await.is_err() { + return false; + } } - } - // Stop when the handle has completed - if handle.is_finished() { - // Drain remaining - while rx.try_recv().is_ok() {} - break; - } - } + true + }); + + // Wait for the run to complete, draining output and watching for Ctrl+C. + let ok = loop { + tokio::select! { + _ = cancel_token.cancelled() => { + handle.abort(); + let _ = tx.send(TuiMsg::Line("Benchmark cancelled.".to_string())); + return; + } + result = &mut handle => { + while run_rx.try_recv().is_ok() {} + break result.unwrap_or(false); + } + Some(_) = run_rx.recv() => { + // drain inner-run messages silently + } + } + }; - let ok = handle.await.unwrap_or(false); - let elapsed = start.elapsed().as_secs_f64() * 1000.0; + let elapsed = start.elapsed().as_secs_f64() * 1000.0; - if !ok { - self.output.push_line("Error: query failed during benchmark"); - return; - } + if !ok { + let _ = tx.send(TuiMsg::Line("Error: query failed during benchmark".to_string())); + return; + } - if i == 0 { - self.output.push_line(format!(" warmup: {:.1}ms", elapsed)); - } else { - self.output.push_line(format!(" run {}: {:.1}ms", i, elapsed)); - times_ms.push(elapsed); + let line = if i == 0 { + format!(" warmup: {:.1}ms", elapsed) + } else { + format!(" run {}/{}: {:.1}ms", i, n_runs, elapsed) + }; + let _ = tx.send(TuiMsg::Line(line)); + if i > 0 { + times_ms.push(elapsed); + } } - } - - if times_ms.is_empty() { - return; - } - // Compute statistics - let min = times_ms.iter().cloned().fold(f64::INFINITY, f64::min); - let max = times_ms.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let avg = times_ms.iter().sum::() / times_ms.len() as f64; - - let mut sorted = times_ms.clone(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let p90_idx = (sorted.len() as f64 * 0.9).ceil() as usize; - let p90 = sorted[p90_idx.saturating_sub(1).min(sorted.len() - 1)]; - - self.output.push_line(format!( - "Results: min={:.1}ms avg={:.1}ms p90={:.1}ms max={:.1}ms", - min, avg, p90, max - )); + if !times_ms.is_empty() { + let min = times_ms.iter().cloned().fold(f64::INFINITY, f64::min); + let max = times_ms.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let avg = times_ms.iter().sum::() / times_ms.len() as f64; + let mut sorted = times_ms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p90_idx = (sorted.len() as f64 * 0.9).ceil() as usize; + let p90 = sorted[p90_idx.saturating_sub(1).min(sorted.len() - 1)]; + let _ = tx.send(TuiMsg::Line(format!( + "Results: min={:.1}ms avg={:.1}ms p90={:.1}ms max={:.1}ms", + min, avg, p90, max + ))); + } + // `tx` dropped here → channel disconnects → is_running = false + }); } /// Called from handle_key: validates that data is available, then queues diff --git a/src/tui_msg.rs b/src/tui_msg.rs index 37953ad..dd35217 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -8,6 +8,8 @@ pub enum TuiMsg { Progress(u64), /// The parsed result of the last successful SELECT query (for csvlens / export). ParsedResult(crate::table_renderer::ParsedResult), + /// Overwrite the running-pane hint line (e.g. benchmark run progress). + RunHint(String), } /// A single rendered line made up of zero or more styled spans. From 63765bc9b5906856625835f1eaa1ee81520b85da Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:01:49 +0100 Subject: [PATCH 072/147] Print custom settings before query in benchmark mode Extracted settings display into push_custom_settings() helper and call it in both execute_queries and do_benchmark after the SQL echo. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ab696e7..d13dda7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -992,6 +992,7 @@ impl TuiApp { if n_runs == 1 { "" } else { "s" } )); self.push_sql_echo(query_text.trim()); + self.push_custom_settings(); // Set up channel + running state, exactly like execute_queries. let (tx, rx) = mpsc::unbounded_channel::(); @@ -1222,18 +1223,15 @@ impl TuiApp { } } - async fn execute_queries(&mut self, original_text: String, queries: Vec) { - // Echo query to output pane with syntax highlighting - self.push_sql_echo(original_text.trim()); - - // Show custom settings (from /set commands) between query echo and result + /// Print custom settings (from /set commands) as a grey line after the SQL echo. + /// Skips URL-construction params that are never user-visible. + fn push_custom_settings(&mut self) { let display_extras: Vec = self .context .args .extra .iter() .filter(|e| { - // Skip params that are always present as part of the URL construction !e.starts_with("database=") && !e.starts_with("format=") && !e.starts_with("query_label=") @@ -1245,6 +1243,12 @@ impl TuiApp { if !display_extras.is_empty() { self.output.push_stat(&format!(" Settings: {}", display_extras.join(", "))); } + } + + async fn execute_queries(&mut self, original_text: String, queries: Vec) { + // Echo query to output pane with syntax highlighting + self.push_sql_echo(original_text.trim()); + self.push_custom_settings(); let (tx, rx) = mpsc::unbounded_channel::(); let cancel_token = CancellationToken::new(); From afd42246982d6b5197bdd07e3e695e3e63ca8d61 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:02:47 +0100 Subject: [PATCH 073/147] Show full expanded query in alias parse error message Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d13dda7..5bd8a35 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -943,7 +943,7 @@ impl TuiApp { self.history.add(cmd.to_string()); self.execute_queries(expanded, queries).await; } else { - self.output.push_line(format!("Error: could not parse expanded query for: {}", cmd)); + self.output.push_line(format!("Error: could not parse query expanded from '{}': {}", cmd, expanded)); } return; } From 39b11b831407bb923a4500fa005a94b5a79d48f2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:04:27 +0100 Subject: [PATCH 074/147] Fix /qh: append semicolon so query parser accepts it Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5bd8a35..eb893e7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -88,7 +88,7 @@ fn expand_command_alias(cmd: &str) -> Option { Some(format!( "SELECT * FROM information_schema.engine_user_query_history \ WHERE start_time > now() - interval '{since_minutes} minutes' \ - ORDER BY start_time DESC LIMIT {limit}" + ORDER BY start_time DESC LIMIT {limit};" )) } _ => None, From 2bcfac224e313e0750e5fcd099bdae463cfc8d0c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:06:13 +0100 Subject: [PATCH 075/147] Add Shift+Drag hint to status bar footer Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index eb893e7..5f6d895 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1832,7 +1832,7 @@ impl TuiApp { let host = &self.context.args.host; let db = &self.context.args.database; - let conn_info = format!(" {} | {}", host, db); + let conn_info = format!(" {} | {} Shift+Drag to select", host, db); // Expire flash messages older than 2 seconds. if let Some((_, t)) = &self.flash_message { From ba1773cc340ae19b8f0c86db2334039d0dcd9790 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:08:18 +0100 Subject: [PATCH 076/147] Reorder footer keybinds; add Shift+Drag to right side Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5f6d895..eefd765 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1832,7 +1832,7 @@ impl TuiApp { let host = &self.context.args.host; let db = &self.context.args.database; - let conn_info = format!(" {} | {} Shift+Drag to select", host, db); + let conn_info = format!(" {} | {}", host, db); // Expire flash messages older than 2 seconds. if let Some((_, t)) = &self.flash_message { @@ -1850,7 +1850,7 @@ impl TuiApp { } else if self.completion_state.is_some() { " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+D exit Ctrl+V viewer Ctrl+Space fuzzy Tab complete Alt+F format Ctrl+H help ".to_string() + " Ctrl+Space fuzzy Ctrl+V viewer Ctrl+D exit Ctrl+H help Alt+F format Shift+Drag select Tab complete ".to_string() }; let total = area.width as usize; From c69ba900b84b1e684e54b26d50296a0106b6a929 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:11:11 +0100 Subject: [PATCH 077/147] Tab completion: complete to common prefix when multiple suggestions share one If all candidates share a prefix beyond what's already typed, insert that prefix immediately instead of opening the popup. A second Tab press then opens the popup to disambiguate the remaining choices. The advance-past-existing-( behaviour is preserved for the unambiguous single-item case only. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index eefd765..38b9821 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -764,16 +764,32 @@ impl TuiApp { let word_start_byte = byte_offset - (cursor_col - word_start_byte_in_line); - // For single-item completions, accept immediately - if items.len() == 1 { - let value = items.into_iter().next().unwrap().value; - let partial_len = cursor_col - word_start_byte_in_line; + // Find the longest common prefix of all item values. + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len] + .bytes() + .zip(item.value.bytes()) + .take_while(|(a, b)| a == b) + .count() + }) + }; + let partial_len = cursor_col - word_start_byte_in_line; + + // If all items share a prefix longer than what's already typed, complete + // to that prefix immediately (like a single-item completion). The user + // can press Tab again to open the popup for the remaining choices. + if common_prefix_len > partial_len || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + let single = items.len() == 1; // Jump to word start, then delete the partial word forward, then insert. // (delete_str deletes FORWARD from cursor, so we must reposition first.) self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, word_start_byte_in_line as u16)); self.textarea.delete_str(partial_len); self.textarea.insert_str(&value); - if next_char_is_paren { + // Only advance past an existing `(` when the completion is unambiguous. + if next_char_is_paren && single { self.textarea.move_cursor(CursorMove::Forward); } return; From cb6192ca7edf694c8383a3b0ed1a717126312cf5 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 11:28:59 +0100 Subject: [PATCH 078/147] Default to client:auto; remove format from saved defaults - Both interactive and non-interactive modes now default to client:auto - --format overrides to any format (no prefix = server-side) - Format is no longer read from or written to ~/.firebolt/fb_config - Fix test_exit_code_on_query_error to check stdout+stderr combined Co-Authored-By: Claude Sonnet 4.6 --- src/args.rs | 29 ++++------------------------- tests/cli.rs | 11 ++++++----- 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/src/args.rs b/src/args.rs index 006ca1b..a16541f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -57,7 +57,7 @@ pub struct Args { pub database: String, #[options(help = "Output format (client:auto, client:vertical, client:horizontal, PSQL, JSON, CSV, ...)")] - #[serde(default)] + #[serde(skip_serializing, skip_deserializing)] pub format: String, #[options(help = "Extra settings in the form --extra =")] @@ -256,12 +256,6 @@ pub fn get_args() -> Result> { if args.update_defaults { args.host = args.host.or(default_host); - if args.core { - args.format = args.format.or(String::from("PSQL")); - } else { - args.format = args.format.or(String::from("TabSeparatedWithNamesAndTypes")); - } - fs::write(&config_path, serde_yaml::to_string(&args)?)?; return Ok(args); } @@ -283,31 +277,16 @@ pub fn get_args() -> Result> { .or(args.core.then(|| String::from("firebolt")).unwrap_or(defaults.database)) .or(String::from("local_dev_db")); - // Detect if running in interactive mode - let is_interactive = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); - if args.core { args.host = args.host.or(String::from("localhost:3473")); args.jwt = String::from(""); - args.format = args.format.or(String::from("PSQL")); } else { - // Apply smart defaults based on mode if format is not already set - let default_format = if args.format.is_empty() && defaults.format.is_empty() { - if is_interactive { - // Interactive mode: default to client-side rendering with auto display - String::from("client:auto") - } else { - // Non-interactive mode: default to server-side rendering with PSQL - String::from("PSQL") - } - } else { - String::new() - }; - - args.format = args.format.or(defaults.format).or(default_format); args.host = args.host.or(defaults.host).or(default_host); } + // Default to client:auto unless --format was explicitly provided. + args.format = args.format.or(String::from("client:auto")); + if !args.extra.is_empty() { let mut extras = normalize_extras(defaults.extra, true)?; extras.append(&mut args.extra); diff --git a/tests/cli.rs b/tests/cli.rs index 443b3a6..ac1ac7b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -248,14 +248,15 @@ fn test_exit_code_on_connection_error() { #[test] fn test_exit_code_on_query_error() { // Test that exit code is non-zero when query returns an error (e.g., syntax error) - let (success, stdout, _) = run_fb(&["--core", "--concise", "SELEC INVALID SYNTAX"]); + let (success, stdout, stderr) = run_fb(&["--core", "--concise", "SELEC INVALID SYNTAX"]); assert!(!success, "Exit code should be non-zero when query fails"); - // The server should return an error message in the response + // The server should return an error message in stdout or stderr + let combined = format!("{}{}", stdout, stderr); assert!( - stdout.to_lowercase().contains("error") || stdout.to_lowercase().contains("exception"), - "stdout should contain error message from server, got: {}", - stdout + combined.to_lowercase().contains("error") || combined.to_lowercase().contains("exception"), + "output should contain error message from server, got stdout: {} stderr: {}", + stdout, stderr ); } From 3c01e426725852454d5f02f5b4f4157da4ef55a8 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 12:04:39 +0100 Subject: [PATCH 079/147] Add structured exit codes: 0=success, 1=query error, 2=system error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code 1: server returned a query-level error (bad SQL, permission denied, planning failure) via JSON error body or HTTP 400. Exit code 2: infrastructure problem — connection refused, auth failure, HTTP 401/403/5xx, response decode error, Ctrl+C cancellation. Also fixes a pre-existing bug where JSON errors in the buffered (non-interactive) response path did not set the failure exit code. Non-QueryFailed errors that previously were silently swallowed in pipe mode are now printed to stderr. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 74 +++++++++++++++++++++++++++++++++++++--------------- src/query.rs | 48 +++++++++++++++++++++++++++------- 2 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index 647cd7b..185b997 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ use auth::maybe_authenticate; use completion::schema_cache::SchemaCache; use completion::usage_tracker::UsageTracker; use context::Context; -use query::query; +use query::{ErrorKind, QueryFailed, query}; use tui::TuiApp; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -28,16 +28,30 @@ pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.3"; #[tokio::main] -async fn main() -> Result<(), Box> { - let args = get_args()?; +async fn main() { + std::process::exit(run().await); +} + +/// Returns the process exit code: +/// 0 — all queries succeeded +/// 1 — one or more queries failed (bad SQL, permission denied, HTTP 400) +/// 2 — system/infrastructure error (connection refused, auth failure, HTTP 4xx/5xx other than 400) +async fn run() -> i32 { + let args = match get_args() { + Ok(a) => a, + Err(e) => { eprintln!("Error: {}", e); return ErrorKind::SystemError as i32; } + }; if args.version { println!("fb-cli version {}", CLI_VERSION); - return Ok(()); + return 0; } let mut context = Context::new(args); - maybe_authenticate(&mut context).await?; + if let Err(e) = maybe_authenticate(&mut context).await { + eprintln!("Error: {}", e); + return ErrorKind::SystemError as i32; + } let query_text = if context.args.command.is_empty() { context.args.query.join(" ") @@ -47,8 +61,10 @@ async fn main() -> Result<(), Box> { // ── Headless mode: query provided on the command line ──────────────────── if !query_text.is_empty() { - query(&mut context, query_text).await?; - return Ok(()); + return match query(&mut context, query_text).await { + Ok(()) => 0, + Err(e) => exit_code_for(&e), + }; } let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); @@ -59,15 +75,18 @@ async fn main() -> Result<(), Box> { use std::io::BufRead; let stdin = std::io::stdin(); let mut buffer = String::new(); - let mut has_error = false; + let mut worst: i32 = 0; for line in stdin.lock().lines() { - let line = line?; + let line = match line { + Ok(l) => l, + Err(e) => { eprintln!("Error reading stdin: {}", e); return ErrorKind::SystemError as i32; } + }; buffer.push_str(&line); // Handle quit/exit before appending a newline (mirrors old REPL behaviour) if buffer.trim() == "quit" || buffer.trim() == "exit" { - buffer.clear(); // don't process as SQL + buffer.clear(); break; } @@ -76,9 +95,7 @@ async fn main() -> Result<(), Box> { let queries = query::try_split_queries(&buffer).unwrap_or_default(); if !queries.is_empty() { for q in queries { - if query(&mut context, q).await.is_err() { - has_error = true; - } + worst = worst.max(run_query(&mut context, q).await); } buffer.clear(); } @@ -89,14 +106,12 @@ async fn main() -> Result<(), Box> { let text = format!("{};", buffer.trim()); if let Some(queries) = query::try_split_queries(&text) { for q in queries { - if query(&mut context, q).await.is_err() { - has_error = true; - } + worst = worst.max(run_query(&mut context, q).await); } } } - return if has_error { Err("One or more queries failed".into()) } else { Ok(()) }; + return worst; } // ── Interactive TUI mode ───────────────────────────────────────────────── @@ -105,11 +120,28 @@ async fn main() -> Result<(), Box> { context.usage_tracker = Some(usage_tracker); let app = TuiApp::new(context, schema_cache); - let had_error = app.run().await?; + match app.run().await { + Ok(had_error) => if had_error { 1 } else { 0 }, + Err(e) => { eprintln!("Error: {}", e); ErrorKind::SystemError as i32 } + } +} + +/// Run a single query and return its exit code (0, 1, or 2). +async fn run_query(context: &mut Context, q: String) -> i32 { + match query(context, q).await { + Ok(()) => 0, + Err(e) => exit_code_for(&e), + } +} - if had_error { - Err("One or more queries failed".into()) +/// Map a query error to an exit code. +/// `QueryFailed` errors have their message already printed; others are printed here. +fn exit_code_for(e: &Box) -> i32 { + if let Some(qf) = e.downcast_ref::() { + qf.0 as i32 } else { - Ok(()) + // Error propagated via `?` (auth failure, network setup, etc.) — print it now. + eprintln!("Error: {}", e); + ErrorKind::SystemError as i32 } } diff --git a/src/query.rs b/src/query.rs index 6cdfd0b..2d126de 100644 --- a/src/query.rs +++ b/src/query.rs @@ -2,10 +2,37 @@ use once_cell::sync::Lazy; use pest::Parser; use pest_derive::Parser; use regex::Regex; +use std::fmt; use std::time::Instant; use tokio::{select, signal, task}; use tokio_util::sync::CancellationToken; +/// Distinguishes between user-caused and system/infrastructure failures. +/// Values double as process exit codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// Bad SQL, permission denied, query rejected by the engine (HTTP 200 error body or HTTP 400). + QueryError = 1, + /// Connection refused, auth failure, HTTP 4xx/5xx other than 400, response decode error. + SystemError = 2, +} + +/// Error returned by [`query()`] that carries an [`ErrorKind`]. +/// The error message has already been printed to stderr before this is returned. +#[derive(Debug)] +pub struct QueryFailed(pub ErrorKind); + +impl fmt::Display for QueryFailed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + ErrorKind::QueryError => write!(f, "Query failed"), + ErrorKind::SystemError => write!(f, "System error"), + } + } +} + +impl std::error::Error for QueryFailed {} + use crate::args::normalize_extras; use crate::auth::authenticate_service_account; use crate::context::Context; @@ -339,7 +366,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< // Build a cancel future: prefer the TUI's CancellationToken, fall back to SIGINT. let cancel = context.query_cancel.clone(); - let mut query_failed = false; + let mut error_kind: Option = None; select! { _ = async { @@ -356,7 +383,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if !context.args.concise { out_err!(context, "^C"); } - query_failed = true; + error_kind = Some(ErrorKind::SystemError); } response = async_resp => { let elapsed = start.elapsed(); @@ -499,12 +526,12 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } else { out_err!(context, "Error: {}", e); } - query_failed = true; + error_kind = Some(ErrorKind::SystemError); } else if let Some(errs) = errors { for err in errs { out_err!(context, "Error: {}", err.description); } - query_failed = true; + error_kind = Some(ErrorKind::QueryError); } else { if !columns.is_empty() { if !display_emitted { @@ -537,7 +564,8 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } if !status.is_success() { - query_failed = true; + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + if error_kind != Some(ErrorKind::SystemError) { error_kind = Some(kind); } } } else { // ── Buffered path (non-interactive or server-rendered) ── @@ -552,6 +580,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< for error in errors { out_err!(context, "Error: {}", error.description); } + error_kind = Some(ErrorKind::QueryError); } else if !parsed.columns.is_empty() { let terminal_width = terminal_size::terminal_size() .map(|(terminal_size::Width(w), _)| w) @@ -582,7 +611,8 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } if !status.is_success() { - query_failed = true; + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + if error_kind != Some(ErrorKind::SystemError) { error_kind = Some(kind); } } } } @@ -592,7 +622,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } else { out_err!(context, "Failed to send the request: {}", error.to_string()); } - query_failed = true; + error_kind = Some(ErrorKind::SystemError); }, }; @@ -612,8 +642,8 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } }; - if query_failed { - Err("Query failed".into()) + if let Some(kind) = error_kind { + Err(Box::new(QueryFailed(kind))) } else { // Track successful query for auto-completion prioritization if let Some(usage_tracker) = &context.usage_tracker { From 337402a3f8af453c68abb2e536c1ba37f56db7d8 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 12:08:49 +0100 Subject: [PATCH 080/147] Update README for ratatui TUI rewrite Documents the new TUI, key bindings, slash commands (/qh, /benchmark, /view, /refresh, /help), Tab/Ctrl+Space completion, Ctrl+R history search, Alt+F SQL formatting, exit codes (0/1/2), client:auto default format, and all current CLI flags. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 435 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 226 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index 74c334f..c303418 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,314 @@ # fb-cli -Firebolt CLI; work with [Firebolt](https://www.firebolt.io/) and [Firebolt Core](https://github.com/firebolt-db/firebolt-core). +Command-line client for [Firebolt](https://www.firebolt.io/) and [Firebolt Core](https://github.com/firebolt-db/firebolt-core). -## Examples +## Quick Start ``` -➤ fb select 42 - ?column? ---------- - 42 +fb --core "SELECT 42" +``` -Time: 41.051ms +``` +fb --host api.us-east-1.app.firebolt.io \ + --extra account_id= \ + --jwt '' \ + -d ``` -### REPL +## Install -``` -➤ fb -=> select 42 - ?column? ---------- - 42 +1. Install `cargo`: https://doc.rust-lang.org/cargo/getting-started/installation.html +2. Install system dependencies (Ubuntu): `sudo apt install pkg-config libssl-dev` +3. Clone, build, and install: -Time: 40.117ms +``` +git clone git@github.com:firebolt-db/fb-cli.git +cd fb-cli +cargo install --path . --locked +``` -=> create table t (a int); -Table 't' already exists. +Run `fb` (or `~/.cargo/bin/fb` if cargo's bin directory is not on `$PATH`). -Time: 87.747ms +## Usage -=> insert into t select * from generate_series(1, 100000000); -/ +**Single query (non-interactive):** +``` +fb "SELECT 42" +fb -c "SELECT 42" +fb --core "SELECT * FROM information_schema.tables LIMIT 5" ``` -Also support history + search in it (`CTRL+R`). +**Interactive REPL:** +``` +fb +fb --core +``` -## Output Formats +**Pipe mode:** +``` +echo "SELECT 1; SELECT 2;" | fb --core +cat queries.sql | fb --core +``` -### Client-Side Rendering +## Interactive REPL -Use `--format client:auto` (default in interactive mode) for pretty table output with smart formatting: +The REPL uses a full-terminal layout: ``` -=> select * from information_schema.engine_query_history limit 3; -+--------------------+-------------+--------+ -| query_id | query_label | status | -+===============================================+ -| abc123... | NULL | ENDED | -| def456... | my_query | ENDED | -| ghi789... | NULL | ENDED | -+--------------------+-------------+--------+ -Time: 15.2ms -Scanned: 3 rows, 1.5 KB (1.2 KB local, 300 B remote) -Request Id: xyz... +┌───────────────────────────────────────────────┐ +│ OUTPUT (scrollable) │ +│ previous queries, results, timing, errors │ +│ │ +├───────────────────────────────────────────────┤ +│ INPUT (multi-line editor) │ +│ SELECT * │ +│ FROM orders │ +│ │ +├───────────────────────────────────────────────┤ +│ localhost:3473 | firebolt Tab complete … │ +└───────────────────────────────────────────────┘ ``` -Available client modes: -- `client:auto` - Smart switching between horizontal/vertical layout -- `client:vertical` - Two-column vertical layout for wide tables -- `client:horizontal` - Standard horizontal table +Enter submits the query when SQL is complete (ends with `;`). An incomplete statement gets a newline instead, allowing natural multi-line editing. -### Interactive Result Exploration +### Key Bindings -Press `Ctrl+V` then `Enter` (or type `\view`) to open the last query result in an interactive viewer powered by [csvlens](https://github.com/YS-L/csvlens). **Note:** Requires client-side output formats (`client:auto`, `client:vertical`, or `client:horizontal`). +| Key | Action | +|-----|--------| +| `Enter` | Submit query (if SQL complete) or insert newline | +| `Ctrl+C` | Cancel current input, or cancel an in-flight query | +| `Ctrl+D` | Exit | +| `Ctrl+Up` / `Ctrl+Down` | Navigate history (older / newer) | +| `Ctrl+R` | Reverse history search | +| `Tab` | Open completion popup (or advance to next item) | +| `Shift+Tab` | Navigate completion popup backward | +| `Ctrl+Space` | Fuzzy schema search (tables, columns, functions) | +| `Ctrl+V` | Open last result in interactive viewer (csvlens) | +| `Alt+F` | Format SQL in the editor (uppercase keywords, 2-space indent) | +| `Page Up` / `Page Down` | Scroll output pane | +| `Ctrl+H` | Show help popup | +| `Escape` | Close any open popup | -``` -=> select * from information_schema.engine_query_history; -[... table output ...] +### Tab Completion -=> \view -[Opens interactive csvlens viewer with sorting, filtering, and navigation] -``` +Tab completion suggests table names, column names, and functions based on the current cursor context (FROM clause, SELECT list, WHERE clause, etc.). The schema is fetched from the server and cached for `--completion-cache-ttl` seconds (default 300). -### Server-Side Rendering +When all suggestions share a common prefix, that prefix is completed immediately (bash-style). A second Tab opens the popup to choose among remaining candidates. -Use format names without prefix for server-rendered output (default in non-interactive/piped mode): -- `PSQL` - PostgreSQL-style format -- `JSON` - JSON output -- `CSV` - CSV format -- `TabSeparatedWithNames` - TSV with headers -- And more... +`Ctrl+Space` opens a fuzzy search overlay that searches the full schema regardless of cursor context. -## Help +### Ctrl+R History Search -``` -➤ fb --help -Usage: fb [OPTIONS] +Incremental reverse search over the session history. Type to filter; `Ctrl+R` again for the next older match; `Up`/`Down` to navigate matches; `Enter` to accept; `Esc` to cancel. -Positional arguments: - query Query command(s) to execute. If not specified, starts the REPL +### Ctrl+V Viewer -Optional arguments: - -c, --command COMMAND Run a single command and exit - -C, --core Preset of settings to connect to Firebolt Core - -h, --host HOSTNAME Hostname (and port) to connect to - -d, --database DATABASE Database name to use - -f, --format FORMAT Output format (client:auto, client:vertical, client:horizontal, TabSeparatedWithNames, PSQL, JSONLines_Compact, ...) - -e, --extra EXTRA Extra settings in the form --extra = - -l, --label LABEL Query label for tracking or identification - -j, --jwt JWT JWT for authentication - --sa-id SA-ID Service Account ID for OAuth authentication - --sa-secret SA-SECRET Service Account Secret for OAuth authentication - --jwt-from-file Load JWT from file (~/.firebolt/jwt) - --oauth-env OAUTH-ENV OAuth environment to use (e.g., 'app' or 'staging'). Used for Service Account authentication (default: staging) - -v, --verbose Enable extra verbose output - --concise Suppress time statistics in output - --hide-pii Hide URLs that may contain PII in query parameters - --no-spinner Disable the spinner in CLI output - --update-defaults Update default configuration values - -V, --version Print version - --help Show help message and exit -``` +Opens the last query result in [csvlens](https://github.com/YS-L/csvlens) — a full-screen interactive table viewer with sorting, filtering, and fuzzy search. Requires a client-side output format (`client:*`). -## Install +## Slash Commands + +Type these directly in the REPL (or pass with `-c`): + +| Command | Description | +|---------|-------------| +| `/help` | Show help popup | +| `/refresh` | Refresh the schema completion cache | +| `/view` | Open last result in csvlens viewer (same as `Ctrl+V`) | +| `/qh [limit] [minutes]` | Show recent query history. Default: 100 rows, last 60 minutes | +| `/benchmark [N] ` | Benchmark a query: 1 warmup + N timed runs (default N=3) | +| `set key=value;` | Set a query parameter | +| `unset key;` | Remove a query parameter | +| `quit` / `exit` | Exit the REPL | + +### `/qh` — Query History -1) Install `cargo`: https://doc.rust-lang.org/cargo/getting-started/installation.html - 1) Add `source "$HOME/.cargo/env"` to your `~/.bashrc` (or `~/.zshrc`). -2) Install `pkg-config`: `sudo apt install pkg-config` (a default dependency for Ubuntu) -3) Install `openssl`: `sudo apt install libssl-dev` (a default dependency for Ubuntu) -4) Clone & Build & Install: ``` -git clone git@github.com:firebolt-db/fb-cli.git -cd fb-cli -cargo install --path . --locked +/qh -- last 100 queries from the past hour +/qh 50 -- last 50 queries from the past hour +/qh 200 1440 -- last 200 queries from the past day ``` -4) That's it: you should be able to run `fb` // or at least `~/.cargo/bin/fb` if cargo env isn't caught up. -## Shortcuts +### `/benchmark` — Benchmark Mode -Most of them from https://github.com/kkawakam/rustyline: +Runs a query multiple times, discards the first result as warmup, and reports timing statistics. Result cache is automatically disabled for accurate measurements. -| Keystroke | Action | -| --------------------- | --------------------------------------------------------------------------- | -| Enter | Finish the line entry | -| Ctrl-R | Reverse Search history (Ctrl-S forward, Ctrl-G cancel) | -| Ctrl-U | Delete from start of line to cursor | -| Ctrl-W | Delete word leading up to cursor (using white space as a word boundary) | -| Ctrl-Y | Paste from Yank buffer | -| Ctrl-\_ | Undo | +``` +/benchmark SELECT count(*) FROM large_table +/benchmark 10 SELECT count(*) FROM large_table +``` -Some of them specific to `fb`: -| Keystroke | Action | -| --------------------- | --------------------------------------------------------------------------- | -| Ctrl-V then Enter | Open last query result in interactive csvlens viewer | -| Ctrl-C | Cancel current input | -| Ctrl-O | Insert a newline | +Output example: +``` + warmup: 412.3ms + run 1/3: 398.1ms [result rows...] + run 2/3: 401.7ms + run 3/3: 395.4ms +Results: min=395.4ms avg=398.4ms p90=401.7ms max=401.7ms +``` +`Ctrl+C` cancels a benchmark in progress. -## Defaults +## Output Formats -Can update defaults one and for all by specifying `--update-defaults`: during this application old defaults are **not** applied. +### Client-Side Rendering (default) -New defaults are going to be stored at `~/.firebolt/fb_config`. +The default format is `client:auto`, which fetches results as JSON and renders them in the terminal. The layout adapts to the number of columns: +- **Horizontal** (few columns): standard bordered table +- **Vertical** (many columns or `client:vertical`): one column per row +- **Auto** (`client:auto`): automatically picks horizontal or vertical based on column count and terminal width ``` -~ ➤ fb select 42 - ?column? ---------- - 42 +=> SELECT query_id, status, duration_usec FROM information_schema.engine_query_history LIMIT 3; ++------------------+---------+--------------+ +| query_id | status | duration_usec | ++==================+=========+==============+ +| abc123... | ENDED | 41051 | +| def456... | ENDED | 40117 | +| ghi789... | ENDED | 87747 | ++------------------+---------+--------------+ +Time: 15.2ms +``` -Time: 40.342ms +Override with `--format client:vertical` or set at runtime: `set format = client:vertical;` -~ ➤ fb select 42 --format CSVWithNames --concise --update-defaults -"?column?" -42 +### Server-Side Rendering -~ ➤ fb select 42 -"?column?" -42 +Pass any Firebolt output format name (without a `client:` prefix) to receive raw server-rendered output: -~ ➤ fb select 42 --verbose # defauls are merged with new args -URL: http://localhost:8123/?database=local_dev_db&mask_internal_errors=1 -QUERY: select 42 -"?column?" -42 +``` +fb --format PSQL "SELECT 42" +fb --format JSON "SELECT 42" +fb --format TabSeparatedWithNamesAndTypes "SELECT 42" ``` -## Queries against FB 2.0 using Service Account +Common formats: `PSQL`, `JSON`, `JSON_Compact`, `JSONLines_Compact`, `CSV`, `CSVWithNames`, `TabSeparatedWithNames`, `TabSeparatedWithNamesAndTypes`. -Specify: -- Service Account ID; -- Service Account Secret; -- Environment +### Changing Format at Runtime -Note: The token is saved in `~/.firebolt/fb_sa_token/` and will be reused if the account ID and secret match and the token is less than half an hour old. +```sql +set format = client:vertical; -- client-side vertical +set format = JSON; -- server-side JSON +unset format; -- reset to default (client:auto) +``` +## Set and Unset -``` -➤ fb --sa-id=${SA_ID} --sa-secret=${SA_SECRET} --oauth-env=app \ - -h ${ACCOUNT_ID}.api.us-east-1.app.firebolt.io -d ${DATABASE_NAME} +Change query parameters at runtime without restarting: + +```sql +set database = my_db; +set engine = my_engine; +set enable_result_cache = false; +unset enable_result_cache; ``` -Read more about getting service accounts [here](https://docs.firebolt.io/guides/managing-your-organization/service-accounts). +Active non-default settings are shown in grey between the query echo and its result. -## Queries against FB 2.0 +## Defaults -Specify: -- host; -- account_id; -- JWT token (can be obtained from browser or other authentication methods); +Save your preferred flags so you don't have to repeat them: ``` -➤ fb --host api.us-east-1.app.firebolt.io --verbose --extra account_id=12312312312 --jwt 'eyJhbGciOiJSUzI1NiI...' +fb --host my-host.firebolt.io --update-defaults +``` + +Saved defaults are stored in `~/.firebolt/fb_config` and merged with any flags you provide at run time. The `--format` flag is intentionally not saved to defaults — always specify it explicitly if you need a non-default format. -=> show engines -URL: https://api.us-east-1.app.firebolt.io/?database=db_1&account_id=12312312&output_format=JSON&advanced_mode=1 -QUERY: show engines -┌─engine_name─────────────┬─engine_owner────────────────┬─type─┬─nodes─┬─clusters─┬─status───────────────┬─auto_start─┬─auto_stop─┬─initially_stopped─┬─url────────────────────────────────────────────────────────────────────────────────────────────────────┬─default_database───────────────────┬─version─┬─last_started──────────────────┬─last_stopped──────────────────┬─description─┐ -│ pre_demo_engine1 │ user@firebolt.io │ S │ 2 │ 1 │ ENGINE_STATE_STOPPED │ t │ 20 │ f │ api.us-east-1.app.firebolt.io?account_id=1321231&engine=pre_demo_engine1 │ │ latest │ 2024-02-07 01:19:19.81689+00 │ 2024-02-07 01:44:15.930845+00 │ │ -│ pre_demo_engine2 │ user@firebolt.io │ S │ 2 │ 1 │ ENGINE_STATE_STOPPED │ t │ 20 │ f │ api.us-east-1.app.firebolt.io?account_id=1321231&engine=pre_demo_engine2 │ pre_demo_validation_testdb1 │ latest │ 2024-02-07 01:21:36.274962+00 │ 2024-02-07 02:32:05.403539+00 │ │ -└─────────────────────────┴─────────────────────────────┴──────┴───────┴──────────┴──────────────────────┴────────────┴───────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────┴─────────┴───────────────────────────────┴───────────────────────────────┴─────────────┘ +## Exit Codes -=> set engine=user5_engine_1 -URL: https://api.us-east-1.app.firebolt.io/?database=db_1&engine=user5_engine_1&account_id=1321231&output_format=JSON&advanced_mode=1 +| Code | Meaning | +|------|---------| +| `0` | All queries succeeded | +| `1` | One or more queries failed (bad SQL, permission denied, HTTP 400) | +| `2` | System/infrastructure error (connection refused, auth failure, HTTP 4xx/5xx) | -=> select 42 -URL: https://api.us-east-1.app.firebolt.io/?database=db_1&engine=user5_engine_1&account_id=1321231&output_format=JSON&advanced_mode=1 -QUERY: select 42 - ?column? ---------- - 42 +This distinction is useful in scripts: -Time: 275.639ms +```bash +fb --core "SELECT ..." +case $? in + 0) echo "ok" ;; + 1) echo "query error — check your SQL" ;; + 2) echo "system error — check connection or credentials" ;; +esac ``` -## Set and Unset +## Firebolt Core -In interactive mode one can dynamically update extra arguments: -- `set key=value;` to set the argument; -- `unset key;` to unset it. +`--core` is a shortcut for `--host localhost:3473 --database firebolt` with no authentication required: ``` -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; - ?column? ---------- - qqq - -Time: 40.745ms +fb --core +fb --core "SELECT 42" +``` -=> set format = Vertical; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; -Row 1: -────── -?column?: qqq +## Authentication -Time: 38.888ms +### JWT -=> set cool_mode=disabled; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db&cool_mode=disabled -QUERY: select E'qqq'; -Unknown setting cool_mode +``` +fb --jwt 'eyJhbGci...' +fb --jwt-from-file # reads ~/.firebolt/jwt +``` -Time: 36.802ms +### Service Account -=> unset cool_mode -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; -Row 1: -────── -?column?: qqq +``` +fb --sa-id --sa-secret --oauth-env app \ + --host .api.us-east-1.app.firebolt.io \ + -d +``` -Time: 39.395ms +The token is cached in `~/.firebolt/fb_sa_token` and reused for up to 30 minutes. -=> set enable_result_cache=disabled; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db&enable_result_cache=disabled -QUERY: select E'qqq'; -Row 1: -────── -qqq: qqq +Read more: [Firebolt Service Accounts](https://docs.firebolt.io/guides/managing-your-organization/service-accounts) -Time: 41.671ms +## All Flags -=> unset enable_result_cache; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; -Row 1: -────── -?column?: qqq +``` +Usage: fb [OPTIONS] [query...] -Time: 39.453ms +Positional arguments: + query Query to execute (starts REPL if omitted) -=> +Optional arguments: + -c, --command COMMAND Run a single command and exit + -C, --core Connect to Firebolt Core (localhost:3473) + -h, --host HOSTNAME Hostname and port + -d, --database DATABASE Database name + -f, --format FORMAT Output format (client:auto, client:vertical, + client:horizontal, PSQL, JSON, CSV, ...) + -e, --extra NAME=VALUE Extra query parameters (repeatable) + -l, --label LABEL Query label for tracking + -j, --jwt JWT JWT token for authentication + --sa-id SA-ID Service Account ID + --sa-secret SA-SECRET Service Account Secret + --jwt-from-file Load JWT from ~/.firebolt/jwt + --oauth-env ENV OAuth environment: app or staging (default: staging) + -v, --verbose Verbose output (shows URL, query text) + --concise Suppress timing statistics + --hide-pii Hide URLs containing query parameters + --no-spinner Disable the query spinner + --no-color Disable syntax highlighting + --no-completion Disable tab completion + --completion-cache-ttl SECS Schema cache TTL in seconds (default: 300) + --min-col-width N Min column width before vertical mode (default: 15) + --max-cell-length N Max cell content length before truncation (default: 1000) + --update-defaults Save current flags as defaults + -V, --version Print version + --help Show help ``` +## Files + +| Path | Purpose | +|------|---------| +| `~/.firebolt/fb_config` | Saved defaults (YAML) | +| `~/.firebolt/fb_history` | REPL history (up to 10,000 entries) | +| `~/.firebolt/fb_sa_token` | Cached service account token | +| `~/.firebolt/jwt` | JWT token file (used with `--jwt-from-file`) | + ## License See [LICENSE](LICENSE.md). From 2ad10987fd2ba74a7b2e902d3df7f8b7d20c75d5 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 12:14:49 +0100 Subject: [PATCH 081/147] Fix ASCII box border, expand /qh docs with parameters and SQL Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c303418..c2166fa 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The REPL uses a full-terminal layout: │ FROM orders │ │ │ ├───────────────────────────────────────────────┤ -│ localhost:3473 | firebolt Tab complete … │ +│ localhost:3473 | firebolt Tab complete … │ └───────────────────────────────────────────────┘ ``` @@ -122,12 +122,27 @@ Type these directly in the REPL (or pass with `-c`): ### `/qh` — Query History +`/qh [limit] [minutes]` + +- `limit` — maximum number of rows to return (default: 100) +- `minutes` — look back window in minutes (default: 60) + ``` /qh -- last 100 queries from the past hour /qh 50 -- last 50 queries from the past hour /qh 200 1440 -- last 200 queries from the past day ``` +Shorthand for: + +```sql +SELECT * +FROM information_schema.engine_user_query_history +WHERE start_time > now() - interval ' minutes' +ORDER BY start_time DESC +LIMIT ; +``` + ### `/benchmark` — Benchmark Mode Runs a query multiple times, discards the first result as warmup, and reports timing statistics. Result cache is automatically disabled for accurate measurements. From c81ae3586350cdf2bfe240e985bd3b9e85b60b06 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 12:17:50 +0100 Subject: [PATCH 082/147] Add scripting section to README; add missing tests README: - New "Scripting" section covering stdout/stderr separation, JSON_Compact and JSONLines_Compact output, exit codes in scripts, and pipe mode behaviour Tests (tests/cli.rs): - run_fb_code() helper returning exact exit code - test_exit_code_query_error_is_1: verifies code=1 for bad SQL - test_exit_code_system_error_is_2: verifies code=2 for connection error - test_exit_command: 'exit' works like 'quit' - test_default_format_is_client_auto: no --format produces table borders - test_stats_on_stderr_not_stdout: timing on stderr, results on stdout - test_concise_suppresses_stats: --concise removes Time: from stderr - test_json_compact_output: JSON_Compact has data/meta fields - test_tsv_output: TabSeparatedWithNamesAndTypes includes header - test_pipe_mode_multiple_queries_in_order: results appear in order - test_pipe_mode_continues_after_error: subsequent queries run after failure Co-Authored-By: Claude Sonnet 4.6 --- README.md | 60 +++++++++++++++++++- tests/cli.rs | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c2166fa..6d12112 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,45 @@ Saved defaults are stored in `~/.firebolt/fb_config` and merged with any flags y | `1` | One or more queries failed (bad SQL, permission denied, HTTP 400) | | `2` | System/infrastructure error (connection refused, auth failure, HTTP 4xx/5xx) | -This distinction is useful in scripts: +## Scripting + +### stdout vs stderr + +Query results are always written to **stdout**. Timing statistics and error messages are written to **stderr**. You can redirect them independently: + +```bash +# Save results to a file, see stats in the terminal +fb --core --format CSV "SELECT * FROM my_table" > results.csv + +# Suppress stats entirely +fb --core --format CSV --concise "SELECT * FROM my_table" > results.csv + +# Capture results and stats separately +fb --core --format CSV "SELECT * FROM my_table" > results.csv 2> stats.txt +``` + +### JSON output + +Use `JSON_Compact` for structured output that is easy to process with tools like `jq`: + +```bash +fb --core --format JSON_Compact --concise "SELECT 1 AS n, 'hello' AS msg" +# {"meta":[{"name":"n","type":"int"},{"name":"msg","type":"text"}],"data":[[1,"hello"]],...} + +# Extract with jq +fb --core --format JSON_Compact --concise "SELECT count(*) AS n FROM my_table" \ + | jq '.data[0][0]' +``` + +Use `JSONLines_Compact` for streaming-friendly line-delimited JSON (one message per line): + +```bash +fb --core --format JSONLines_Compact --concise "SELECT 42 AS value" \ + | grep '^{"message_type":"data"' \ + | jq '.data[0][0]' +``` + +### Exit codes in scripts ```bash fb --core "SELECT ..." @@ -249,6 +287,26 @@ case $? in esac ``` +```bash +# Fail fast on any error +set -e +fb --core --concise "INSERT INTO log SELECT now(), 'start';" +fb --core --concise "SELECT count(*) FROM my_table;" +fb --core --concise "INSERT INTO log SELECT now(), 'done';" +``` + +### Pipe mode + +When stdin is not a terminal, fb reads queries line-by-line. All queries are executed even if one fails; the exit code reflects the worst failure: + +```bash +{ + echo "SELECT 1;" + echo "SELECT 2;" + echo "SELECT 3;" +} | fb --core --format TabSeparatedWithNamesAndTypes --concise +``` + ## Firebolt Core `--core` is a shortcut for `--host localhost:3473 --database firebolt` with no authentication required: diff --git a/tests/cli.rs b/tests/cli.rs index ac1ac7b..81c93e3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -14,6 +14,20 @@ fn run_fb(args: &[&str]) -> (bool, String, String) { (output.status.success(), stdout, stderr) } +/// Like run_fb but returns the exact exit code instead of a bool. +fn run_fb_code(args: &[&str]) -> (i32, String, String) { + let output = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(args) + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + let code = output.status.code().unwrap_or(-1); + + (code, stdout, stderr) +} + #[test] fn test_basic_query() { let (success, stdout, _) = run_fb(&["--core", "SELECT 42"]); @@ -384,3 +398,146 @@ fn test_server_format_psql() { assert!(!stdout.contains('+')); // No table borders (PSQL style is different) assert!(stdout.contains("id")); } + +// ── Exit code precision ────────────────────────────────────────────────────── + +#[test] +fn test_exit_code_query_error_is_1() { + let (code, _, _) = run_fb_code(&["--core", "--concise", "SELEC INVALID SYNTAX"]); + assert_eq!(code, 1, "bad SQL should exit with code 1, not {}", code); +} + +#[test] +fn test_exit_code_system_error_is_2() { + let (code, _, stderr) = run_fb_code(&["--host", "localhost:59999", "--concise", "SELECT 1"]); + assert_eq!(code, 2, "connection error should exit with code 2, not {}; stderr: {}", code, stderr); +} + +// ── exit command ───────────────────────────────────────────────────────────── + +#[test] +fn test_exit_command() { + // 'exit' should work the same as 'quit' + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "SELECT 99;").unwrap(); + writeln!(stdin, "exit").unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + assert!(stdout.contains("99")); +} + +// ── Default format ─────────────────────────────────────────────────────────── + +#[test] +fn test_default_format_is_client_auto() { + // Without --format, output should use client:auto (bordered table) + let (success, stdout, _) = run_fb(&["--core", "SELECT 1 as id"]); + assert!(success); + assert!(stdout.contains('+'), "default format should produce table borders (client:auto)"); + assert!(stdout.contains("id")); +} + +// ── stdout / stderr separation ─────────────────────────────────────────────── + +#[test] +fn test_stats_on_stderr_not_stdout() { + // Results go to stdout, timing stats go to stderr — critical for scripting + let (success, stdout, stderr) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42"]); + assert!(success); + assert!(!stdout.contains("Time:"), "stdout should not contain timing stats"); + assert!(stderr.contains("Time:"), "stderr should contain timing stats"); +} + +#[test] +fn test_concise_suppresses_stats() { + // Without --concise: stats on stderr + let (success, _, stderr) = run_fb(&["--core", "SELECT 1"]); + assert!(success); + assert!(stderr.contains("Time:"), "without --concise, stderr should contain timing"); + + // With --concise: no stats + let (success, _, stderr) = run_fb(&["--core", "--concise", "SELECT 1"]); + assert!(success); + assert!(!stderr.contains("Time:"), "--concise should suppress timing stats"); +} + +// ── Scripting output formats ───────────────────────────────────────────────── + +#[test] +fn test_json_compact_output() { + let (success, stdout, _) = run_fb(&["--core", "--concise", "--format=JSON_Compact", "SELECT 1 AS n"]); + assert!(success); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()) + .expect("JSON_Compact output should be valid JSON"); + assert!(parsed.get("data").is_some(), "JSON_Compact should have a 'data' field"); + assert!(parsed.get("meta").is_some(), "JSON_Compact should have a 'meta' field"); +} + +#[test] +fn test_tsv_output() { + let (success, stdout, _) = run_fb(&["--core", "--concise", "--format=TabSeparatedWithNamesAndTypes", "SELECT 42 AS answer"]); + assert!(success); + assert!(stdout.contains("answer"), "TabSeparatedWithNamesAndTypes should include header"); + assert!(stdout.contains("42")); +} + +// ── Pipe mode ──────────────────────────────────────────────────────────────── + +#[test] +fn test_pipe_mode_multiple_queries_in_order() { + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "SELECT 10;").unwrap(); + writeln!(stdin, "SELECT 20;").unwrap(); + writeln!(stdin, "SELECT 30;").unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + // All three results should appear in order + let pos10 = stdout.find("10").expect("first result missing"); + let pos20 = stdout.find("20").expect("second result missing"); + let pos30 = stdout.find("30").expect("third result missing"); + assert!(pos10 < pos20 && pos20 < pos30, "results should appear in order"); +} + +#[test] +fn test_pipe_mode_continues_after_error() { + // A failed query in the middle should not abort subsequent queries + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "SELECT 10;").unwrap(); + writeln!(stdin, "SELEC INVALID;").unwrap(); // fails + writeln!(stdin, "SELECT 30;").unwrap(); // should still run + drop(stdin); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(!output.status.success(), "exit code should be non-zero due to failed query"); + assert!(stdout.contains("10"), "first query result should appear"); + assert!(stdout.contains("30"), "third query result should appear despite middle failure"); +} From 17330a087c34b63a43050a71d8e1740411fc6ccc Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 13:38:57 +0100 Subject: [PATCH 083/147] Fix Ctrl+H toggle, textarea highlight scroll, output pane wrapping - Ctrl+H now toggles the help overlay (pressing while open closes it) - Fix syntax highlighting when textarea is scrolled horizontally or vertically: mirror tui-textarea's internal viewport state each frame and use the scroll offsets in apply_textarea_highlights - Add line wrapping in the output pane: long lines now wrap at terminal width instead of overflowing off the right edge; scroll tracking updated to count visual (wrapped) lines Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 60 +++++++++++++++++++++++++--- src/tui/output_pane.rs | 91 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 15 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 38b9821..4ba9348 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -158,6 +158,13 @@ pub struct TuiApp { /// `None` when the cursor is not inside a function call. signature_hint: Option<(String, Vec)>, + /// Mirrors the scroll position that tui-textarea uses internally. + /// Kept in sync each render frame so `apply_textarea_highlights` can map + /// character columns to correct screen positions even when the textarea is + /// scrolled horizontally or vertically. + ta_row_top: u16, + ta_col_top: u16, + // After leaving alt-screen for csvlens we need a full redraw needs_clear: bool, should_quit: bool, @@ -215,6 +222,8 @@ impl TuiApp { help_visible: false, history_search: None, signature_hint: None, + ta_row_top: 0, + ta_col_top: 0, needs_clear: false, should_quit: false, has_error: false, @@ -395,7 +404,7 @@ impl TuiApp { // ── Ctrl+H help overlay ────────────────────────────────────────────── // Handled before everything else so it works during queries too. if key.code == KeyCode::Char('h') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.help_visible = true; + self.help_visible = !self.help_visible; return false; } // Escape or q closes the help overlay (and nothing else). @@ -1357,8 +1366,8 @@ impl TuiApp { let layout = compute_layout(area, input_height); - // Clamp scroll so it stays in bounds - self.output.clamp_scroll(layout.output.height); + // Clamp scroll so it stays in bounds (width needed for wrapped-line count) + self.output.clamp_scroll(layout.output.height, layout.output.width); // Output pane self.output.render(layout.output, f.buffer_mut()); @@ -1384,6 +1393,17 @@ impl TuiApp { f.render_widget(prompt, chunks[0]); f.render_widget(&self.textarea, chunks[1]); + // Mirror tui-textarea's internal viewport update so that + // apply_textarea_highlights can map character positions to correct + // screen coordinates even when the content is scrolled. + let (cursor_row, cursor_col) = self.textarea.cursor(); + self.ta_row_top = Self::next_scroll_top( + self.ta_row_top, cursor_row as u16, chunks[1].height, + ); + self.ta_col_top = Self::next_scroll_top( + self.ta_col_top, cursor_col as u16, chunks[1].width, + ); + // Apply per-token syntax highlighting to the rendered textarea buffer let textarea_area = chunks[1]; self.apply_textarea_highlights(f.buffer_mut(), textarea_area); @@ -1447,6 +1467,19 @@ impl TuiApp { self.render_status_bar(f, layout.status); } + /// Replicate tui-textarea's internal scroll-top calculation. + /// Given the previous top row/col and the current cursor position, returns + /// the new top row/col that keeps the cursor inside the visible area. + fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 { + if cursor < prev_top { + cursor + } else if len > 0 && prev_top + len <= cursor { + cursor + 1 - len + } else { + prev_top + } + } + /// Apply syntax highlighting to the textarea by post-processing the ratatui buffer. /// /// After `tui-textarea` renders its content (cursor, selection, etc.) we walk @@ -1466,10 +1499,19 @@ impl TuiApp { } let (cursor_row, cursor_col) = self.textarea.cursor(); + let row_scroll = self.ta_row_top as usize; + let col_scroll = self.ta_col_top as usize; let mut byte_offset = 0usize; for (line_idx, line) in lines.iter().enumerate() { - let screen_y = area.y + line_idx as u16; + // Rows scrolled above the visible area are not rendered. + if line_idx < row_scroll { + byte_offset += line.len() + 1; + continue; + } + + let visible_row = line_idx - row_scroll; + let screen_y = area.y + visible_row as u16; if screen_y >= area.y + area.height { break; } @@ -1477,7 +1519,15 @@ impl TuiApp { let mut char_col = 0usize; for (byte_in_line, _ch) in line.char_indices() { let byte_pos = byte_offset + byte_in_line; - let screen_x = area.x + char_col as u16; + + // Characters scrolled to the left of the visible area are not rendered. + if char_col < col_scroll { + char_col += 1; + continue; + } + + let visible_col = char_col - col_scroll; + let screen_x = area.x + visible_col as u16; if screen_x >= area.x + area.width { break; } diff --git a/src/tui/output_pane.rs b/src/tui/output_pane.rs index 63ffd2d..4737551 100644 --- a/src/tui/output_pane.rs +++ b/src/tui/output_pane.rs @@ -153,8 +153,12 @@ impl OutputPane { } /// Clamp scroll so we don't go past the end of content. - pub fn clamp_scroll(&mut self, visible_height: u16) { - let total = self.lines.len(); + /// `visible_width` is used to compute the number of visual (wrapped) lines. + pub fn clamp_scroll(&mut self, visible_height: u16, visible_width: u16) { + let width = visible_width as usize; + let total: usize = self.lines.iter() + .map(|ol| visual_line_count(&ol.content, width)) + .sum(); let height = visible_height as usize; if total <= height { self.scroll = 0; @@ -164,29 +168,96 @@ impl OutputPane { // else: user-scrolled position is still in range, leave it alone } - /// Render only the visible slice of lines — O(visible_height), not O(total). + /// Render only the visible slice of lines. /// Content is bottom-anchored: empty padding fills the top so output /// grows upward from the input area, like a normal terminal. + /// Long lines are wrapped at `area.width` characters. pub fn render(&self, area: Rect, buf: &mut Buffer) { - let start = self.scroll; - let end = (start + area.height as usize).min(self.lines.len()); + let width = area.width as usize; let height = area.height as usize; - let content: Vec = self.lines[start..end] + // Expand stored lines into visual (wrapped) lines. + let visual: Vec> = self.lines .iter() - .map(|l| l.content.clone()) + .flat_map(|ol| wrap_line(&ol.content, width)) .collect(); + let start = self.scroll; + let end = (start + height).min(visual.len()); + let slice = &visual[start..end]; + // Pad with blank lines above so content sits at the bottom. - let padding = height.saturating_sub(content.len()); - let visible: Vec = std::iter::repeat_n(Line::raw(""), padding) - .chain(content) + let padding = height.saturating_sub(slice.len()); + let visible: Vec> = std::iter::repeat_n(Line::raw(""), padding) + .chain(slice.iter().cloned()) .collect(); Widget::render(Paragraph::new(visible), area, buf); } } +// ── Line wrapping helpers ───────────────────────────────────────────────────── + +/// Count how many visual lines a stored `Line` occupies when the terminal is +/// `width` columns wide. One empty line still counts as one visual line. +fn visual_line_count(line: &Line<'static>, width: usize) -> usize { + if width == 0 { + return 1; + } + let chars: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); + if chars == 0 { 1 } else { chars.div_ceil(width) } +} + +/// Expand a `Line<'static>` into one or more visual lines of at most `width` +/// characters, splitting across span boundaries as needed. +fn wrap_line(line: &Line<'static>, width: usize) -> Vec> { + if width == 0 { + return vec![line.clone()]; + } + let total: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); + if total <= width { + return vec![line.clone()]; + } + + let mut result: Vec> = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut current_len: usize = 0; + + for span in &line.spans { + let mut seg_start = 0usize; // byte offset into span.content for the current segment + + for (byte_idx, _ch) in span.content.char_indices() { + if current_len == width { + // Flush: push the portion of this span accumulated so far. + if byte_idx > seg_start { + current_spans.push(Span::styled( + span.content[seg_start..byte_idx].to_string(), + span.style, + )); + } + result.push(Line::from(std::mem::take(&mut current_spans))); + current_len = 0; + seg_start = byte_idx; + } + current_len += 1; + } + + // Flush the remainder of this span. + if seg_start < span.content.len() { + current_spans.push(Span::styled( + span.content[seg_start..].to_string(), + span.style, + )); + } + } + + if !current_spans.is_empty() { + result.push(Line::from(current_spans)); + } + + result +} + // ── TuiSpan → ratatui Span conversion ──────────────────────────────────────── fn tui_span_to_ratatui(span: TuiSpan) -> Span<'static> { From 3694d2295aa14b0ba8933eabd3837b88d5d1b922 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 14:13:25 +0100 Subject: [PATCH 084/147] Add Ctrl+Z undo and Ctrl+Y redo in the editor Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4ba9348..3eb5622 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -578,6 +578,16 @@ impl TuiApp { self.output.scroll_down(10); } + // ── Undo / Redo ─────────────────────────────────────────────── + (KeyCode::Char('z'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.undo(); + self.completion_state = None; + } + (KeyCode::Char('y'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.redo(); + self.completion_state = None; + } + // ── All other keys → textarea ───────────────────────────────── _ => { let input = Input::from(key); From 68ffbfa7fdaa2fc1b0e65ea71dd937313dfdea47 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 14:14:47 +0100 Subject: [PATCH 085/147] Add mouse click support: textarea cursor positioning and popup selection Left-click in the textarea moves the cursor to the clicked character. Left-click on a completion popup item selects and inserts it. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/completion_popup.rs | 8 ++++++ src/tui/mod.rs | 51 ++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index efcf1b9..c5e4aa5 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -66,6 +66,14 @@ impl CompletionState { } } + /// Jump selection directly to `index` (clamped to valid range). + pub fn select_at(&mut self, index: usize) { + if index < self.items.len() { + self.selected = index; + self.ensure_visible(); + } + } + /// Move selection to the previous item (wrapping), scrolling the viewport. pub fn prev(&mut self) { if !self.items.is_empty() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3eb5622..57b72dc 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -165,6 +165,11 @@ pub struct TuiApp { ta_row_top: u16, ta_col_top: u16, + /// Textarea screen area from the last render frame — used to hit-test mouse clicks. + last_textarea_area: ratatui::layout::Rect, + /// Completion popup screen area from the last render frame (`None` when no popup). + last_popup_rect: Option, + // After leaving alt-screen for csvlens we need a full redraw needs_clear: bool, should_quit: bool, @@ -224,6 +229,8 @@ impl TuiApp { signature_hint: None, ta_row_top: 0, ta_col_top: 0, + last_textarea_area: ratatui::layout::Rect::default(), + last_popup_rect: None, needs_clear: false, should_quit: false, has_error: false, @@ -316,7 +323,10 @@ impl TuiApp { Event::Mouse(mouse) => match mouse.kind { MouseEventKind::ScrollUp => self.output.scroll_up(8), MouseEventKind::ScrollDown => self.output.scroll_down(8), - _ => {} // ignore clicks — Shift+drag still works for terminal selection + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + self.handle_mouse_click(mouse.column, mouse.row); + } + _ => {} }, Event::Resize(_, _) => {} // redraw on next tick _ => {} @@ -843,6 +853,39 @@ impl TuiApp { } } + /// Handle a left-mouse-button click at terminal position `(col, row)`. + /// + /// Priority: + /// 1. Click inside the completion popup → select that item and accept. + /// 2. Click inside the textarea → move the cursor to the clicked position. + fn handle_mouse_click(&mut self, col: u16, row: u16) { + let pos = ratatui::layout::Position::new(col, row); + + // 1. Completion popup click. + if let Some(popup) = self.last_popup_rect { + if popup.contains(pos) { + // Border takes 1 row at the top; items start at popup.y + 1. + if row >= popup.y + 1 && row < popup.y + popup.height.saturating_sub(1) { + let item_idx = (row - popup.y - 1) as usize; + if let Some(cs) = &mut self.completion_state { + cs.select_at(item_idx + cs.scroll_offset); + } + self.accept_completion(); + } + return; + } + } + + // 2. Textarea click — move cursor to the clicked character. + if self.last_textarea_area.contains(pos) && !self.is_running { + // Map screen position to content position accounting for scroll. + let target_row = (row - self.last_textarea_area.y) as u16 + self.ta_row_top; + let target_col = (col - self.last_textarea_area.x) as u16 + self.ta_col_top; + self.textarea.move_cursor(CursorMove::Jump(target_row, target_col)); + self.completion_state = None; + } + } + /// Key handler active while the completion popup is open. /// Returns `true` only if the app should quit (never, but matches signature). async fn handle_completion_key(&mut self, key: crossterm::event::KeyEvent) -> bool { @@ -1414,11 +1457,15 @@ impl TuiApp { self.ta_col_top, cursor_col as u16, chunks[1].width, ); + // Record textarea area for mouse-click hit testing. + self.last_textarea_area = chunks[1]; + // Apply per-token syntax highlighting to the rendered textarea buffer let textarea_area = chunks[1]; self.apply_textarea_highlights(f.buffer_mut(), textarea_area); // Render completion popup if open (not during history search) + self.last_popup_rect = None; if self.history_search.is_none() { if let Some(cs) = &self.completion_state { if !cs.is_empty() { @@ -1429,6 +1476,8 @@ impl TuiApp { area, ); completion_popup::render(cs, popup_rect, f); + // Record for mouse-click hit testing. + self.last_popup_rect = Some(popup_rect); } } } From 854e02cb407e7f2fe0a14288e90f2c5373fcd53a Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 14:20:35 +0100 Subject: [PATCH 086/147] Implement Ctrl+E editor, /run file, /watch, and update docs - Ctrl+E: opens current query in \$VISUAL/\$EDITOR/vi, reads back on save - /run : executes all SQL from a file; Tab completes file paths - /watch [N] : re-runs query every N seconds (default 5); Ctrl+C stops - Refactor run_viewer into suspend_tui/resume_tui helpers shared with run_editor - Update Ctrl+H help popup and README with new keys and commands Co-Authored-By: Claude Sonnet 4.6 --- README.md | 5 + src/tui/mod.rs | 382 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 377 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6d12112..a116776 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ Enter submits the query when SQL is complete (ends with `;`). An incomplete stat | `Shift+Tab` | Navigate completion popup backward | | `Ctrl+Space` | Fuzzy schema search (tables, columns, functions) | | `Ctrl+V` | Open last result in interactive viewer (csvlens) | +| `Ctrl+E` | Open current query in `$EDITOR` (falls back to `vi`) | +| `Ctrl+Z` | Undo last edit | +| `Ctrl+Y` | Redo | | `Alt+F` | Format SQL in the editor (uppercase keywords, 2-space indent) | | `Page Up` / `Page Down` | Scroll output pane | | `Ctrl+H` | Show help popup | @@ -116,6 +119,8 @@ Type these directly in the REPL (or pass with `-c`): | `/view` | Open last result in csvlens viewer (same as `Ctrl+V`) | | `/qh [limit] [minutes]` | Show recent query history. Default: 100 rows, last 60 minutes | | `/benchmark [N] ` | Benchmark a query: 1 warmup + N timed runs (default N=3) | +| `/run ` | Execute all SQL queries from a file (Tab completes the file path) | +| `/watch [N] ` | Re-run query every N seconds (default 5); `Ctrl+C` stops | | `set key=value;` | Set a query parameter | | `unset key;` | Remove a query parameter | | `quit` / `exit` | Exit the REPL | diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 57b72dc..1a60df7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -74,6 +74,116 @@ fn parse_benchmark_args(cmd: &str) -> (usize, String) { (3, rest) } +/// Parse `/watch [N] ` arguments. +/// Returns `(interval_secs, query_text)` where `interval_secs` defaults to 5. +fn parse_watch_args(cmd: &str) -> (u64, String) { + let rest = cmd + .strip_prefix("/watch") + .unwrap_or(cmd) + .trim() + .to_string(); + + let mut parts = rest.splitn(2, |c: char| c.is_whitespace()); + if let Some(first) = parts.next() { + if let Ok(n) = first.parse::() { + let query = parts.next().unwrap_or("").trim().to_string(); + return (n.max(1), query); + } + } + (5, rest) +} + +/// Build file-path completion candidates for the partial path `partial`. +/// Directories are listed with a trailing `/` and description `"dir"`; +/// files with description `"file"`. Results are sorted: directories first, +/// then alphabetically within each group. +fn complete_file_paths(partial: &str) -> Vec { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + use std::path::Path; + + // Expand leading `~` + let expanded: std::borrow::Cow = if partial.starts_with('~') { + if let Some(home) = dirs::home_dir() { + std::borrow::Cow::Owned(format!("{}{}", home.display(), &partial[1..])) + } else { + std::borrow::Cow::Borrowed(partial) + } + } else { + std::borrow::Cow::Borrowed(partial) + }; + + let path = Path::new(expanded.as_ref()); + let (dir, prefix) = if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) { + (path, "") + } else { + let parent = path.parent().unwrap_or(Path::new(".")); + let fname = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + (parent, fname) + }; + + let read_dir = std::fs::read_dir(dir).unwrap_or_else(|_| { + // If dir doesn't exist yet, try "." so we still return something + std::fs::read_dir(".").unwrap() + }); + + let mut dirs_list: Vec = Vec::new(); + let mut files_list: Vec = Vec::new(); + + for entry in read_dir.flatten() { + let name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + if !name.starts_with(prefix) { + continue; + } + + // Reconstruct the value to insert (keep the directory prefix typed so far) + let dir_prefix = if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) || dir == Path::new(".") && !partial.contains('/') { + if partial.ends_with('/') { + partial.to_string() + } else { + // No dir prefix typed + String::new() + } + } else { + // Preserve the directory part the user typed + let dir_str = path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(""); + if dir_str.is_empty() { + String::new() + } else { + format!("{}/", dir_str) + } + }; + + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + dirs_list.push(CompletionItem { + value: format!("{}{}/", dir_prefix, name), + description: "dir".to_string(), + item_type: ItemType::Table, + }); + } else { + files_list.push(CompletionItem { + value: format!("{}{}", dir_prefix, name), + description: "file".to_string(), + item_type: ItemType::Table, + }); + } + } + + dirs_list.sort_by(|a, b| a.value.cmp(&b.value)); + files_list.sort_by(|a, b| a.value.cmp(&b.value)); + dirs_list.extend(files_list); + dirs_list +} + /// Expand a command alias to a full SQL query, returning `None` if the command /// is not a known alias. /// @@ -181,6 +291,9 @@ pub struct TuiApp { /// When true, open the csvlens viewer at the top of the next event-loop tick /// (outside any event-handler so crossterm's global reader is fully idle). pending_viewer: bool, + + /// When true, open $EDITOR at the top of the next event-loop tick. + pending_editor: bool, } impl TuiApp { @@ -236,6 +349,7 @@ impl TuiApp { has_error: false, flash_message: None, pending_viewer: false, + pending_editor: false, } } @@ -290,12 +404,16 @@ impl TuiApp { terminal: &mut Terminal>, ) -> Result> { loop { - // ── Launch csvlens here, before event::poll, so crossterm's global - // reader is fully idle (not inside any event handler). + // ── Launch csvlens / $EDITOR here, before event::poll, so crossterm's + // global reader is fully idle (not inside any event handler). if self.pending_viewer { self.pending_viewer = false; self.run_viewer(terminal); } + if self.pending_editor { + self.pending_editor = false; + self.run_editor(terminal); + } self.drain_query_output(); @@ -483,6 +601,11 @@ impl TuiApp { self.open_viewer(); } + // ── Ctrl+E: open in $EDITOR ─────────────────────────────────── + (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => { + self.pending_editor = true; + } + // ── Ctrl+Space: open fuzzy schema search ───────────────────── (KeyCode::Char(' '), m) if m.contains(KeyModifiers::CONTROL) => { self.open_fuzzy_search(); @@ -757,6 +880,30 @@ impl TuiApp { return; } + // ── /run path completion ─────────────────────────────────── + { + let (cursor_row, cursor_col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + let first_line = lines.first().map(|s| s.as_str()).unwrap_or(""); + if first_line.starts_with("/run ") && cursor_row == 0 { + let run_prefix_len = "/run ".len(); + let partial = &first_line[run_prefix_len..cursor_col.min(first_line.len())]; + let items = complete_file_paths(partial); + if !items.is_empty() { + let word_start_byte = lines[..cursor_row].iter().map(|l| l.len() + 1).sum::() + run_prefix_len; + let cs = CompletionState::new( + items, + word_start_byte, + run_prefix_len, + cursor_row, + false, + ); + self.completion_state = Some(cs); + } + return; + } + } + // Compute current cursor position in terms of the full content let (cursor_row, cursor_col) = self.textarea.cursor(); let lines = self.textarea.lines(); @@ -1006,6 +1153,22 @@ impl TuiApp { // ── Slash commands ──────────────────────────────────────────────────────── async fn handle_slash_command(&mut self, cmd: &str) { + // Dispatch /watch + if cmd.starts_with("/watch") { + self.history.add(cmd.to_string()); + let (interval_secs, query_text) = parse_watch_args(cmd); + self.do_watch(interval_secs, query_text).await; + return; + } + + // Dispatch /run + if cmd.starts_with("/run ") { + let path_str = cmd[5..].trim().to_string(); + self.history.add(cmd.to_string()); + self.do_run_file(&path_str).await; + return; + } + // Dispatch /benchmark separately since it needs async query execution if cmd.starts_with("/benchmark") { self.history.add(cmd.to_string()); @@ -1183,6 +1346,135 @@ impl TuiApp { }); } + /// Execute all SQL statements from the file at `path_str`. + async fn do_run_file(&mut self, path_str: &str) { + // Expand leading ~ + let expanded = if path_str.starts_with('~') { + if let Some(home) = dirs::home_dir() { + format!("{}{}", home.display(), &path_str[1..]) + } else { + path_str.to_string() + } + } else { + path_str.to_string() + }; + + let content = match std::fs::read_to_string(&expanded) { + Ok(c) => c, + Err(e) => { + self.output.push_error(&format!("Error: could not read '{}': {}", path_str, e)); + return; + } + }; + + let queries = match crate::query::try_split_queries(&content) { + Some(q) if !q.is_empty() => q, + _ => { + self.output.push_error("Error: no queries found in file"); + return; + } + }; + + self.execute_queries(content, queries).await; + } + + /// Re-run `query_text` every `interval_secs` seconds until Ctrl+C. + async fn do_watch(&mut self, interval_secs: u64, query_text: String) { + if query_text.trim().is_empty() { + self.output.push_line( + "Usage: /watch [N] (default interval N=5 seconds)", + ); + return; + } + + self.output.push_line(format!( + "Watching every {}s — Ctrl+C to stop:", + interval_secs + )); + self.push_sql_echo(query_text.trim()); + self.push_custom_settings(); + + let (tx, rx) = mpsc::unbounded_channel::(); + let cancel_token = CancellationToken::new(); + self.query_rx = Some(rx); + self.cancel_token = Some(cancel_token.clone()); + self.is_running = true; + self.running_hint.clear(); + self.progress_rows = 0; + self.query_start = Some(Instant::now()); + + let query_text = query_text.trim().to_string(); + let ctx = self.context.clone(); + + tokio::spawn(async move { + let watch_start = Instant::now(); + let mut interval = + tokio::time::interval(Duration::from_secs(interval_secs)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut run_count = 0u64; + + loop { + // Wait for the next tick (first tick is immediate) + tokio::select! { + _ = cancel_token.cancelled() => { + let _ = tx.send(TuiMsg::Line("Watch stopped.".to_string())); + return; + } + _ = interval.tick() => {} + } + + run_count += 1; + let elapsed = watch_start.elapsed().as_secs(); + let _ = tx.send(TuiMsg::Line(format!( + "── watch run {} (+{}s) ──────────────────────────", + run_count, elapsed + ))); + + let queries = match crate::query::try_split_queries(&query_text) { + Some(q) if !q.is_empty() => q, + _ => { + let _ = tx.send(TuiMsg::Line("Error: could not parse query".to_string())); + return; + } + }; + + let (run_tx, mut run_rx) = mpsc::unbounded_channel::(); + let mut run_ctx = ctx.clone(); + run_ctx.tui_output_tx = Some(run_tx); + run_ctx.query_cancel = Some(cancel_token.clone()); + + let mut handle = tokio::spawn(async move { + for q in queries { + if crate::query::query(&mut run_ctx, q).await.is_err() { + return; + } + } + }); + + // Drain inner-run messages and watch for cancellation + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + handle.abort(); + let _ = tx.send(TuiMsg::Line("Watch stopped.".to_string())); + return; + } + _ = &mut handle => { + // Forward any remaining buffered messages + while let Ok(msg) = run_rx.try_recv() { + let _ = tx.send(msg); + } + break; + } + Some(msg) = run_rx.recv() => { + let _ = tx.send(msg); + } + } + } + } + }); + } + /// Called from handle_key: validates that data is available, then queues /// the viewer to open at the top of the next event-loop iteration. fn open_viewer(&mut self) { @@ -1197,19 +1489,18 @@ impl TuiApp { self.pending_viewer = true; } - /// Actually launches csvlens. Called at the top of event_loop, outside any - /// event-handler, so crossterm's global InternalEventReader is fully idle. - fn run_viewer(&mut self, terminal: &mut Terminal>) { - // Mirror exactly what run() does on exit: tear down raw mode + alt screen - // through the ratatui backend's BufWriter so everything is flushed in order. + /// Tear down raw mode and alternate screen so an external program can use + /// the terminal. Must be paired with a call to `resume_tui`. + fn suspend_tui(terminal: &mut Terminal>) { let _ = disable_raw_mode(); let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); let _ = std::io::Write::flush(terminal.backend_mut()); + } - let result = open_csvlens_viewer(&self.context); - - // Mirror exactly what run() does on entry: restore raw mode + alt screen. + /// Restore raw mode and alternate screen after an external program has + /// finished. Pairs with `suspend_tui`. + fn resume_tui(terminal: &mut Terminal>) { let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); let _ = execute!( terminal.backend_mut(), @@ -1218,12 +1509,78 @@ impl TuiApp { let _ = enable_raw_mode(); let _ = std::io::Write::flush(terminal.backend_mut()); let _ = terminal.clear(); + } + + /// Actually launches csvlens. Called at the top of event_loop, outside any + /// event-handler, so crossterm's global InternalEventReader is fully idle. + fn run_viewer(&mut self, terminal: &mut Terminal>) { + Self::suspend_tui(terminal); + let result = open_csvlens_viewer(&self.context); + Self::resume_tui(terminal); if let Err(e) = result { self.set_flash(format!("Viewer error: {}", e)); } } + /// Open the current textarea content in `$VISUAL` / `$EDITOR` / `vi`. + /// Called at the top of event_loop so the terminal reader is idle. + fn run_editor(&mut self, terminal: &mut Terminal>) { + // Write current content to a temp file. + let pid = std::process::id(); + let tmp_path = format!("/tmp/fb_edit_{}.sql", pid); + let content = self.textarea.lines().join("\n"); + if let Err(e) = std::fs::write(&tmp_path, &content) { + self.set_flash(format!("Editor error: could not create temp file: {}", e)); + return; + } + + // Resolve editor command. + let editor_cmd = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_string()); + let mut parts = editor_cmd.split_whitespace(); + let bin = match parts.next() { + Some(b) => b.to_string(), + None => { + self.set_flash("Editor error: EDITOR variable is empty"); + let _ = std::fs::remove_file(&tmp_path); + return; + } + }; + let extra_args: Vec = parts.map(|s| s.to_string()).collect(); + + Self::suspend_tui(terminal); + + let status = std::process::Command::new(&bin) + .args(&extra_args) + .arg(&tmp_path) + .status(); + + Self::resume_tui(terminal); + + match status { + Ok(s) if s.success() => { + // Read the edited content back into the textarea. + match std::fs::read_to_string(&tmp_path) { + Ok(new_content) => { + let trimmed = new_content.trim_end_matches('\n').to_string(); + self.set_textarea_content(&trimmed); + } + Err(e) => { + self.set_flash(format!("Editor error: could not read file: {}", e)); + } + } + } + Ok(_) => {} // Editor exited with non-zero; keep original content + Err(e) => { + self.set_flash(format!("Editor error: could not launch '{}': {}", bin, e)); + } + } + + let _ = std::fs::remove_file(&tmp_path); + } + /// Show a temporary error message in the status bar for ~2 seconds. fn set_flash(&mut self, msg: impl Into) { self.flash_message = Some((msg.into(), Instant::now())); @@ -1845,8 +2202,11 @@ impl TuiApp { ("Ctrl+D", "Exit"), ("Ctrl+C", "Cancel input / cancel running query"), ("Ctrl+V", "Open last result in csvlens viewer"), + ("Ctrl+E", "Open current query in $EDITOR"), ("Ctrl+R", "Reverse history search"), ("Ctrl+Space", "Fuzzy schema search"), + ("Ctrl+Z", "Undo last edit"), + ("Ctrl+Y", "Redo"), ("Tab", "Open / navigate completion popup"), ("Shift+Tab", "Navigate completion popup backwards"), ("Alt+F", "Format SQL (uppercase keywords, 2-space indent)"), @@ -1858,6 +2218,8 @@ impl TuiApp { ("/help", "Show this help"), ("/set k=v", "Set a query parameter"), ("/unset k", "Remove a query parameter"), + ("/run ", "Execute SQL from a file (Tab completes path)"), + ("/watch [N] ", "Re-run query every N seconds (default 5); Ctrl+C stops"), ("/benchmark [N]", "Run query N times (default 3) and report timings"), ("/qh [limit] [min]", "Query history (default: last 100 in 60 min)"), ]; From 44e4346f20c22b9bda5189738d037fc76faaec82 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 14:27:09 +0100 Subject: [PATCH 087/147] Status bar: cursor position and Ctrl+E hint; /run file header and truncated echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status bar shows L{row}:C{col} cursor position when textarea is visible - Idle hint includes Ctrl+E editor (not Ctrl+Z/Y) - /run prints "Running: " header before the SQL echo - SQL echo for /run is truncated to first 5 lines with … marker if longer Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1a60df7..28e3459 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -74,6 +74,20 @@ fn parse_benchmark_args(cmd: &str) -> (usize, String) { (3, rest) } +/// Return the first `max_lines` lines of `sql`. +/// If the input has more lines, an `…` line is appended so the caller can see +/// that the content was cut. +fn sql_preview(sql: &str, max_lines: usize) -> String { + let lines: Vec<&str> = sql.lines().collect(); + if lines.len() <= max_lines { + lines.join("\n") + } else { + let mut preview = lines[..max_lines].to_vec(); + preview.push("…"); + preview.join("\n") + } +} + /// Parse `/watch [N] ` arguments. /// Returns `(interval_secs, query_text)` where `interval_secs` defaults to 5. fn parse_watch_args(cmd: &str) -> (u64, String) { @@ -1375,7 +1389,10 @@ impl TuiApp { } }; - self.execute_queries(content, queries).await; + // Show filename header then a truncated SQL preview (≤5 lines). + self.output.push_line(format!("Running: {}", path_str)); + let preview = sql_preview(content.trim(), 5); + self.execute_queries(preview, queries).await; } /// Re-run `query_text` every `interval_secs` seconds until Ctrl+C. @@ -2319,7 +2336,15 @@ impl TuiApp { let host = &self.context.args.host; let db = &self.context.args.database; - let conn_info = format!(" {} | {}", host, db); + // Show cursor position (1-based) when the textarea is visible. + let cursor_pos = if !self.is_running { + let (row, col) = self.textarea.cursor(); + format!(" L{}:C{} ", row + 1, col + 1) + } else { + String::new() + }; + + let conn_info = format!(" {} | {}{}", host, db, cursor_pos); // Expire flash messages older than 2 seconds. if let Some((_, t)) = &self.flash_message { @@ -2337,7 +2362,7 @@ impl TuiApp { } else if self.completion_state.is_some() { " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+Space fuzzy Ctrl+V viewer Ctrl+D exit Ctrl+H help Alt+F format Shift+Drag select Tab complete ".to_string() + " Ctrl+Space fuzzy Ctrl+E editor Ctrl+V viewer Ctrl+D exit Ctrl+H help Alt+F format Tab complete ".to_string() }; let total = area.width as usize; From b9cf31c97f82996621e7929370afaf20d980864e Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 14:54:58 +0100 Subject: [PATCH 088/147] @file syntax, slash completion, Ctrl-R cursor, help popup overhaul - /run, /benchmark, /watch accept @ or inline SQL - Tab at `/` completes command names; Tab at `/run @` completes file paths - `@` is suggested on Tab after the command when no arg typed yet - Ctrl-R: Left/Right arrows and Ctrl+A navigate within the search query; cursor rendered at correct position in search line - Help popup: add Enter/Shift+Alt+Enter, Ctrl+H, Mouse click, Shift+Drag, /exit, updated @file descriptions; dynamic width (no unnecessary wraps) - Shift+Enter / Alt+Enter always insert newline (even after `;`) - Arrow Down into history: cursor placed at first line; Arrow Up into history: cursor placed at last line - Footer: Ctrl+H and Ctrl+D moved to front - Remove /help slash command (use Ctrl+H); add /exit slash command - README: updated key bindings, @file section, updated slash commands Co-Authored-By: Claude Sonnet 4.6 --- README.md | 33 +++- src/tui/history_search.rs | 53 +++++- src/tui/mod.rs | 379 +++++++++++++++++++++++++------------- 3 files changed, 330 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index a116776..d8fee9f 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,10 @@ Enter submits the query when SQL is complete (ends with `;`). An incomplete stat | Key | Action | |-----|--------| | `Enter` | Submit query (if SQL complete) or insert newline | +| `Shift+Enter` / `Alt+Enter` | Always insert newline (even after `;`) | | `Ctrl+C` | Cancel current input, or cancel an in-flight query | | `Ctrl+D` | Exit | +| `Ctrl+H` | Show / hide help popup | | `Ctrl+Up` / `Ctrl+Down` | Navigate history (older / newer) | | `Ctrl+R` | Reverse history search | | `Tab` | Open completion popup (or advance to next item) | @@ -89,7 +91,6 @@ Enter submits the query when SQL is complete (ends with `;`). An incomplete stat | `Ctrl+Y` | Redo | | `Alt+F` | Format SQL in the editor (uppercase keywords, 2-space indent) | | `Page Up` / `Page Down` | Scroll output pane | -| `Ctrl+H` | Show help popup | | `Escape` | Close any open popup | ### Tab Completion @@ -102,7 +103,7 @@ When all suggestions share a common prefix, that prefix is completed immediately ### Ctrl+R History Search -Incremental reverse search over the session history. Type to filter; `Ctrl+R` again for the next older match; `Up`/`Down` to navigate matches; `Enter` to accept; `Esc` to cancel. +Incremental reverse search over the session history. Type to filter; `Ctrl+R` again for the next older match; `Up`/`Down` to navigate matches; `Left`/`Right` to move within the search query; `Ctrl+A` to jump to the start; `Enter` to accept; `Esc` to cancel. ### Ctrl+V Viewer @@ -114,16 +115,34 @@ Type these directly in the REPL (or pass with `-c`): | Command | Description | |---------|-------------| -| `/help` | Show help popup | +| `/exit` | Exit the REPL (also: `/quit`, `exit`, `quit`) | | `/refresh` | Refresh the schema completion cache | | `/view` | Open last result in csvlens viewer (same as `Ctrl+V`) | | `/qh [limit] [minutes]` | Show recent query history. Default: 100 rows, last 60 minutes | -| `/benchmark [N] ` | Benchmark a query: 1 warmup + N timed runs (default N=3) | -| `/run ` | Execute all SQL queries from a file (Tab completes the file path) | -| `/watch [N] ` | Re-run query every N seconds (default 5); `Ctrl+C` stops | +| `/run @` | Execute all SQL queries from a file (Tab completes path) | +| `/run ` | Execute an inline SQL query | +| `/benchmark [N] @\|` | Benchmark a query: 1 warmup + N timed runs (default N=3) | +| `/watch [N] @\|` | Re-run query every N seconds (default 5); `Ctrl+C` stops | | `set key=value;` | Set a query parameter | | `unset key;` | Remove a query parameter | -| `quit` / `exit` | Exit the REPL | + +### `@` syntax + +`/run`, `/benchmark`, and `/watch` all accept either an inline SQL query or a reference to a file using the `@` prefix: + +``` +/run @~/queries/report.sql +/run SELECT 42; + +/benchmark 10 @query.sql +/benchmark SELECT count(*) FROM large_table; + +/watch 5 @monitor.sql +/watch 2 SELECT now(); +``` + +Tab after `/run ` suggests `@`. Tab after `/run @` completes file names. +Tab at `/` shows all available commands. ### `/qh` — Query History diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index aa06791..a49e345 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -7,6 +7,8 @@ pub const MAX_VISIBLE: usize = 16; pub struct HistorySearch { /// The characters the user has typed so far. query: String, + /// Byte offset of the insertion cursor within `query`. + cursor_pos: usize, /// Indices into `History::entries` of all matching entries, most-recent-first. all_matches: Vec, /// Which match is currently highlighted (index into `all_matches`, 0 = most recent). @@ -23,6 +25,7 @@ impl HistorySearch { pub fn new(saved_content: String, entries: &[String]) -> Self { let mut s = Self { query: String::new(), + cursor_pos: 0, all_matches: Vec::new(), selected: 0, scroll_offset: 0, @@ -36,6 +39,16 @@ impl HistorySearch { &self.query } + /// The part of the query before the cursor (for rendering). + pub fn query_before_cursor(&self) -> &str { + &self.query[..self.cursor_pos] + } + + /// The part of the query at and after the cursor (for rendering). + pub fn query_after_cursor(&self) -> &str { + &self.query[self.cursor_pos..] + } + pub fn all_matches(&self) -> &[usize] { &self.all_matches } @@ -93,15 +106,51 @@ impl HistorySearch { // ── Public mutation ────────────────────────────────────────────────────── pub fn push_char(&mut self, c: char, entries: &[String]) { - self.query.push(c); + self.query.insert(self.cursor_pos, c); + self.cursor_pos += c.len_utf8(); self.recompute(entries); } pub fn pop_char(&mut self, entries: &[String]) { - self.query.pop(); + if self.cursor_pos == 0 { + return; + } + // Find start of the character just before cursor_pos. + let new_pos = self.query[..self.cursor_pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + self.query.remove(new_pos); + self.cursor_pos = new_pos; self.recompute(entries); } + /// Move the cursor one character to the left. + pub fn move_cursor_left(&mut self) { + if self.cursor_pos == 0 { + return; + } + self.cursor_pos = self.query[..self.cursor_pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + + /// Move the cursor one character to the right. + pub fn move_cursor_right(&mut self) { + if self.cursor_pos < self.query.len() { + let ch = self.query[self.cursor_pos..].chars().next().unwrap(); + self.cursor_pos += ch.len_utf8(); + } + } + + /// Move the cursor to the start of the query (Ctrl+A). + pub fn cursor_to_start(&mut self) { + self.cursor_pos = 0; + } + /// Move selection to the next older match (Up arrow / Ctrl+R). /// `visible_rows` is the height of the list area for scroll tracking. pub fn select_older(&mut self, visible_rows: usize) { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 28e3459..f44dcc3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -88,6 +88,48 @@ fn sql_preview(sql: &str, max_lines: usize) -> String { } } +/// Read the file at `path`, expanding a leading `~` to the home directory. +/// Returns the file contents on success or a human-readable error string. +fn read_file_content(path: &str) -> Result { + let expanded: std::borrow::Cow = if path.starts_with('~') { + if let Some(home) = dirs::home_dir() { + std::borrow::Cow::Owned(format!("{}{}", home.display(), &path[1..])) + } else { + std::borrow::Cow::Borrowed(path) + } + } else { + std::borrow::Cow::Borrowed(path) + }; + std::fs::read_to_string(expanded.as_ref()) + .map_err(|e| format!("Error: could not read '{}': {}", path, e)) +} + +/// Return the column index in `first_line` where the "query / file" argument +/// starts for `/run`, `/benchmark [N]`, and `/watch [N]` commands. +/// +/// For `/benchmark` and `/watch`, an optional leading integer (the run count / +/// interval) is skipped; the return value points at the first character of the +/// actual query or `@` token. +fn find_slash_arg_col(first_line: &str) -> Option { + if first_line.starts_with("/run ") { + return Some("/run ".len()); + } + for prefix in ["/benchmark ", "/watch "] { + if let Some(rest) = first_line.strip_prefix(prefix) { + let base = prefix.len(); + let first_tok = rest.split_whitespace().next().unwrap_or(""); + if !first_tok.is_empty() && first_tok.parse::().is_ok() { + // Skip past the numeric token and trailing whitespace. + let after_num = rest[first_tok.len()..].trim_start(); + let skip = rest.len() - after_num.len(); + return Some(base + skip); + } + return Some(base); + } + } + None +} + /// Parse `/watch [N] ` arguments. /// Returns `(interval_secs, query_text)` where `interval_secs` defaults to 5. fn parse_watch_args(cmd: &str) -> (u64, String) { @@ -642,8 +684,10 @@ impl TuiApp { } } - // ── Shift+Enter: always insert newline ─────────────────────── - (KeyCode::Enter, m) if m.contains(KeyModifiers::SHIFT) => { + // ── Shift+Enter / Alt+Enter: always insert newline ─────────── + (KeyCode::Enter, m) + if m.contains(KeyModifiers::SHIFT) || m.contains(KeyModifiers::ALT) => + { self.textarea.insert_newline(); } @@ -689,6 +733,8 @@ impl TuiApp { (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { if let Some(entry) = self.history.go_forward() { self.set_textarea_content(&entry); + self.textarea.move_cursor(CursorMove::Top); + self.textarea.move_cursor(CursorMove::Head); } } (KeyCode::Up, _) => { @@ -710,6 +756,8 @@ impl TuiApp { if row >= last_row { if let Some(entry) = self.history.go_forward() { self.set_textarea_content(&entry); + self.textarea.move_cursor(CursorMove::Top); + self.textarea.move_cursor(CursorMove::Head); } } else { // Move cursor within the buffer; keep history navigation active. @@ -810,6 +858,25 @@ impl TuiApp { self.preview_history_match(&entries); } + // Ctrl+A: move cursor to beginning of search query + (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { + if let Some(hs) = &mut self.history_search { + hs.cursor_to_start(); + } + } + + // Left / Right: navigate cursor within the search query + (KeyCode::Left, _) => { + if let Some(hs) = &mut self.history_search { + hs.move_cursor_left(); + } + } + (KeyCode::Right, _) => { + if let Some(hs) = &mut self.history_search { + hs.move_cursor_right(); + } + } + // Printable character: append to search query (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { if let Some(hs) = &mut self.history_search { @@ -894,27 +961,99 @@ impl TuiApp { return; } - // ── /run path completion ─────────────────────────────────── + // ── Slash-command completion ────────────────────────────────────────── { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; let (cursor_row, cursor_col) = self.textarea.cursor(); - let lines = self.textarea.lines(); - let first_line = lines.first().map(|s| s.as_str()).unwrap_or(""); - if first_line.starts_with("/run ") && cursor_row == 0 { - let run_prefix_len = "/run ".len(); - let partial = &first_line[run_prefix_len..cursor_col.min(first_line.len())]; - let items = complete_file_paths(partial); - if !items.is_empty() { - let word_start_byte = lines[..cursor_row].iter().map(|l| l.len() + 1).sum::() + run_prefix_len; - let cs = CompletionState::new( - items, - word_start_byte, - run_prefix_len, - cursor_row, - false, - ); - self.completion_state = Some(cs); + // Owned copy so we can borrow self.textarea mutably later. + let first_line: String = self.textarea.lines().first().cloned().unwrap_or_default(); + + if cursor_row == 0 && first_line.starts_with('/') { + let line_to_cursor = &first_line[..cursor_col.min(first_line.len())]; + + // Case A: no space before cursor → complete the command name itself. + if !line_to_cursor.contains(' ') { + let all_cmds: &[(&str, &str)] = &[ + ("/benchmark", "cmd"), + ("/exit", "cmd"), + ("/qh", "cmd"), + ("/refresh", "cmd"), + ("/run", "cmd"), + ("/view", "cmd"), + ("/watch", "cmd"), + ]; + let items: Vec = all_cmds.iter() + .filter(|(c, _)| c.starts_with(line_to_cursor)) + .map(|(c, d)| CompletionItem { + value: format!("{} ", c), + description: d.to_string(), + item_type: ItemType::Table, + }) + .collect(); + if !items.is_empty() { + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > cursor_col || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, 0)); + self.textarea.delete_str(cursor_col); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new(items, 0, 0, 0, false); + self.completion_state = Some(cs); + } + } + return; + } + + // Case B: space found → @file argument completion for /run, /benchmark, /watch. + if let Some(arg_col) = find_slash_arg_col(&first_line) { + if cursor_col >= arg_col { + let partial = &first_line[arg_col..cursor_col.min(first_line.len())]; + + if partial.starts_with('@') { + // File path completion after the '@' prefix. + let file_partial = &partial[1..]; + let items = complete_file_paths(file_partial); + if !items.is_empty() { + let file_start_col = arg_col + 1; + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > file_partial.len() || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, file_start_col as u16)); + self.textarea.delete_str(file_partial.len()); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new( + items, file_start_col, file_start_col, 0, false, + ); + self.completion_state = Some(cs); + } + } + } else if partial.is_empty() { + // Suggest '@' to indicate file mode. + let items = vec![CompletionItem { + value: "@".to_string(), + description: "file".to_string(), + item_type: ItemType::Table, + }]; + let cs = CompletionState::new(items, arg_col, arg_col, 0, false); + self.completion_state = Some(cs); + } + return; + } } - return; } } @@ -1167,33 +1306,73 @@ impl TuiApp { // ── Slash commands ──────────────────────────────────────────────────────── async fn handle_slash_command(&mut self, cmd: &str) { - // Dispatch /watch - if cmd.starts_with("/watch") { - self.history.add(cmd.to_string()); - let (interval_secs, query_text) = parse_watch_args(cmd); - self.do_watch(interval_secs, query_text).await; + // /exit and /quit + if cmd == "/exit" || cmd == "/quit" { + self.should_quit = true; return; } - // Dispatch /run - if cmd.starts_with("/run ") { - let path_str = cmd[5..].trim().to_string(); + // /run — accepts inline SQL or @ + if cmd.starts_with("/run") { + let arg = cmd["/run".len()..].trim(); + if arg.is_empty() { + self.output.push_line("Usage: /run @ or /run "); + return; + } self.history.add(cmd.to_string()); - self.do_run_file(&path_str).await; + if let Some(path) = arg.strip_prefix('@') { + match read_file_content(path) { + Ok(content) => match crate::query::try_split_queries(&content) { + Some(queries) if !queries.is_empty() => { + self.output.push_line(format!("/run @{}", path)); + let preview = sql_preview(content.trim(), 5); + self.execute_queries(preview, queries).await; + } + _ => self.output.push_error("Error: no queries found in file"), + }, + Err(e) => self.output.push_error(&e), + } + } else { + match crate::query::try_split_queries(arg) { + Some(queries) if !queries.is_empty() => { + self.execute_queries(arg.to_string(), queries).await; + } + _ => self.output.push_error(&format!("Error: could not parse query: {}", arg)), + } + } return; } - // Dispatch /benchmark separately since it needs async query execution + // /benchmark — accepts inline SQL or @ if cmd.starts_with("/benchmark") { self.history.add(cmd.to_string()); let (n_runs, query_text) = parse_benchmark_args(cmd); - self.do_benchmark(n_runs, query_text).await; + let resolved = if let Some(path) = query_text.strip_prefix('@') { + match read_file_content(path) { + Ok(c) => c.trim().to_string(), + Err(e) => { self.output.push_error(&e); return; } + } + } else { query_text }; + self.do_benchmark(n_runs, resolved).await; return; } - // Dispatch command aliases (e.g. /qh) + // /watch — accepts inline SQL or @ + if cmd.starts_with("/watch") { + self.history.add(cmd.to_string()); + let (interval_secs, query_text) = parse_watch_args(cmd); + let resolved = if let Some(path) = query_text.strip_prefix('@') { + match read_file_content(path) { + Ok(c) => c.trim().to_string(), + Err(e) => { self.output.push_error(&e); return; } + } + } else { query_text }; + self.do_watch(interval_secs, resolved).await; + return; + } + + // Command aliases (e.g. /qh) if let Some(expanded) = expand_command_alias(cmd) { - // Execute the expanded SQL query as if the user typed it if let Some(queries) = crate::query::try_split_queries(&expanded) { self.history.add(cmd.to_string()); self.execute_queries(expanded, queries).await; @@ -1206,7 +1385,6 @@ impl TuiApp { match cmd { "/view" => self.open_viewer(), "/refresh" | "/refresh_cache" => self.do_refresh(), - "/help" => self.show_help(), _ => { match handle_meta_command(&mut self.context, cmd) { Ok(true) => {} @@ -1360,41 +1538,6 @@ impl TuiApp { }); } - /// Execute all SQL statements from the file at `path_str`. - async fn do_run_file(&mut self, path_str: &str) { - // Expand leading ~ - let expanded = if path_str.starts_with('~') { - if let Some(home) = dirs::home_dir() { - format!("{}{}", home.display(), &path_str[1..]) - } else { - path_str.to_string() - } - } else { - path_str.to_string() - }; - - let content = match std::fs::read_to_string(&expanded) { - Ok(c) => c, - Err(e) => { - self.output.push_error(&format!("Error: could not read '{}': {}", path_str, e)); - return; - } - }; - - let queries = match crate::query::try_split_queries(&content) { - Some(q) if !q.is_empty() => q, - _ => { - self.output.push_error("Error: no queries found in file"); - return; - } - }; - - // Show filename header then a truncated SQL preview (≤5 lines). - self.output.push_line(format!("Running: {}", path_str)); - let preview = sql_preview(content.trim(), 5); - self.execute_queries(preview, queries).await; - } - /// Re-run `query_text` every `interval_secs` seconds until Ctrl+C. async fn do_watch(&mut self, interval_secs: u64, query_text: String) { if query_text.trim().is_empty() { @@ -1619,34 +1762,6 @@ impl TuiApp { }); } - fn show_help(&mut self) { - self.output.push_ansi_text( - "Keyboard shortcuts:\n\ - Ctrl+V - Open last result in csvlens viewer\n\ - Ctrl+R - Reverse history search\n\ - Ctrl+Space - Fuzzy schema search\n\ - Tab - Open / navigate completion popup\n\ - Shift+Tab - Navigate completion popup backwards\n\ - Alt+F - Format SQL\n\ - Ctrl+D - Exit\n\ - Ctrl+C - Cancel current input (or running query)\n\ - Page Up/Down - Scroll output\n\ - \n\ - Special commands:\n\ - \\refresh - Manually refresh schema cache\n\ - \\benchmark [N] - Run query N+1 times (1 warmup), show timing stats\n\ - \\help - Show this help message\n\ - \n\ - SQL-style commands:\n\ - set format = ; - Change output format\n\ - unset format; - Reset format to default\n\ - set completion = on/off; - Enable/disable auto-completion\n\ - Ctrl+Up/Down - Cycle history\n\ - Page Up/Down - Scroll output pane\n\ - Shift+Enter - Insert newline without submitting", - ); - } - // ── Query execution ────────────────────────────────────────────────────── /// Echo SQL text to the output pane with the same syntax highlighting as the input pane. @@ -2058,8 +2173,9 @@ impl TuiApp { // ── Search line (bottom) ──────────────────────────────────────────── let search_line = Line::from(vec![ Span::styled("/ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - Span::styled(hs.query().to_string(), Style::default().fg(Color::White)), + Span::styled(hs.query_before_cursor().to_string(), Style::default().fg(Color::White)), Span::styled("█", Style::default().fg(Color::Cyan)), + Span::styled(hs.query_after_cursor().to_string(), Style::default().fg(Color::White)), ]); f.render_widget(Paragraph::new(search_line), chunks[1]); @@ -2216,40 +2332,50 @@ impl TuiApp { let cmd_style = Style::default().fg(Color::LightCyan); let keybinds: &[(&str, &str)] = &[ - ("Ctrl+D", "Exit"), - ("Ctrl+C", "Cancel input / cancel running query"), - ("Ctrl+V", "Open last result in csvlens viewer"), - ("Ctrl+E", "Open current query in $EDITOR"), - ("Ctrl+R", "Reverse history search"), - ("Ctrl+Space", "Fuzzy schema search"), - ("Ctrl+Z", "Undo last edit"), - ("Ctrl+Y", "Redo"), - ("Tab", "Open / navigate completion popup"), - ("Shift+Tab", "Navigate completion popup backwards"), - ("Alt+F", "Format SQL (uppercase keywords, 2-space indent)"), - ("Page Up/Down", "Scroll output pane"), - ("Ctrl+Up/Down", "Cycle through history"), - ("Escape", "Close any open popup"), + ("Enter", "Submit query (if complete) or insert newline"), + ("Shift/Alt+Enter", "Always insert newline (even after `;`)"), + ("Ctrl+H", "Show / hide this help"), + ("Ctrl+D", "Exit"), + ("Ctrl+C", "Cancel input / cancel running query"), + ("Ctrl+V", "Open last result in csvlens viewer"), + ("Ctrl+E", "Open current query in $EDITOR"), + ("Ctrl+R", "Reverse history search"), + ("Ctrl+Space", "Fuzzy schema search"), + ("Ctrl+Up/Down", "Cycle through history (older / newer)"), + ("Ctrl+Z", "Undo last edit"), + ("Ctrl+Y", "Redo"), + ("Tab", "Open / navigate completion popup"), + ("Shift+Tab", "Navigate completion popup backwards"), + ("Alt+F", "Format SQL (uppercase keywords, 2-space indent)"), + ("Page Up/Down", "Scroll output pane"), + ("Mouse click", "Move cursor to clicked position"), + ("Shift+Drag", "Select text (most terminals)"), + ("Escape", "Close any open popup"), ]; let commands: &[(&str, &str)] = &[ - ("/help", "Show this help"), - ("/set k=v", "Set a query parameter"), - ("/unset k", "Remove a query parameter"), - ("/run ", "Execute SQL from a file (Tab completes path)"), - ("/watch [N] ", "Re-run query every N seconds (default 5); Ctrl+C stops"), - ("/benchmark [N]", "Run query N times (default 3) and report timings"), - ("/qh [limit] [min]", "Query history (default: last 100 in 60 min)"), + ("/exit", "Exit the REPL (also: /quit, exit, quit)"), + ("/run @", "Execute SQL queries from a file"), + ("/run ", "Execute an inline SQL query"), + ("/benchmark [N] @|", "Benchmark N timed runs + 1 warmup"), + ("/watch [N] @|", "Re-run every N secs; Ctrl+C stops"), + ("/qh [limit] [min]", "Query history (default: 100 rows, 60 min)"), + ("/refresh", "Refresh the schema completion cache"), + ("/view", "Open last result in csvlens viewer"), + ("set k=v;", "Set a query parameter"), + ("unset k;", "Remove a query parameter"), ]; - // Determine column widths + // Determine column widths dynamically from content. let key_col = keybinds.iter().chain(commands.iter()).map(|(k, _)| k.len()).max().unwrap_or(14) + 2; + let max_desc = keybinds.iter().chain(commands.iter()).map(|(_, d)| d.len()).max().unwrap_or(30); + let sep_len = key_col + max_desc.max(28); let mut lines: Vec = Vec::new(); // Section: keyboard shortcuts lines.push(Line::from(Span::styled(" Keyboard shortcuts", section_style))); lines.push(Line::from(Span::styled( - format!(" {}", "─".repeat(key_col + 30)), + format!(" {}", "─".repeat(sep_len)), sep_style, ))); for (key, desc) in keybinds { @@ -2265,9 +2391,9 @@ impl TuiApp { lines.push(Line::from("")); // Section: special commands - lines.push(Line::from(Span::styled(" Special commands", section_style))); + lines.push(Line::from(Span::styled(" Slash commands", section_style))); lines.push(Line::from(Span::styled( - format!(" {}", "─".repeat(key_col + 30)), + format!(" {}", "─".repeat(sep_len)), sep_style, ))); for (cmd, desc) in commands { @@ -2286,8 +2412,9 @@ impl TuiApp { Span::styled("Press Esc or q to close", sep_style), ])); - // Sizing: fixed inner content width + borders - let content_w = (key_col + 2 + 46) as u16 + 4; // key col + desc + padding + borders + // Sizing: compute popup width from actual content width. + let inner_w = 2 + sep_len; // " " prefix + separator/item content + let content_w = inner_w as u16 + 2; // + 2 for left/right borders let content_h = lines.len() as u16 + 2; let popup_w = content_w.min(area.width.saturating_sub(6)); @@ -2362,7 +2489,7 @@ impl TuiApp { } else if self.completion_state.is_some() { " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+Space fuzzy Ctrl+E editor Ctrl+V viewer Ctrl+D exit Ctrl+H help Alt+F format Tab complete ".to_string() + " Ctrl+H help Ctrl+D exit Ctrl+Space fuzzy Ctrl+E editor Ctrl+V viewer Alt+F format Tab complete ".to_string() }; let total = area.width as usize; From e551651393757ea75009f8e497de3dcf0123a8a2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:09:08 +0100 Subject: [PATCH 089/147] Fix double-slash in @/ completion; quoted filenames for @file; history search cursor style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - complete_file_paths: derive dir_prefix via rfind('/') to fix @/ → //tmp double-slash - /run, /benchmark, /watch: strip outer single/double quotes from @file paths - Tab completion: support @"path and @'path — strip quote before completing, wrap results with matching quote (closing quote omitted for dirs and when one already follows the cursor) - History search cursor: render char-under-cursor with cyan background instead of inserting a █ glyph between characters Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 100 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f44dcc3..9bab895 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -88,6 +88,20 @@ fn sql_preview(sql: &str, max_lines: usize) -> String { } } +/// Strip a single layer of matching outer single or double quotes from `s`. +/// `"my file.sql"` → `my file.sql`; `'foo'` → `foo`; `bare` → `bare`. +fn strip_outer_quotes(s: &str) -> &str { + if s.len() >= 2 { + let b = s.as_bytes(); + if (b[0] == b'"' && b[b.len() - 1] == b'"') + || (b[0] == b'\'' && b[b.len() - 1] == b'\'') + { + return &s[1..s.len() - 1]; + } + } + s +} + /// Read the file at `path`, expanding a leading `~` to the home directory. /// Returns the file contents on success or a human-readable error string. fn read_file_content(path: &str) -> Result { @@ -197,25 +211,12 @@ fn complete_file_paths(partial: &str) -> Vec continue; } - // Reconstruct the value to insert (keep the directory prefix typed so far) - let dir_prefix = if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) || dir == Path::new(".") && !partial.contains('/') { - if partial.ends_with('/') { - partial.to_string() - } else { - // No dir prefix typed - String::new() - } - } else { - // Preserve the directory part the user typed - let dir_str = path - .parent() - .and_then(|p| p.to_str()) - .unwrap_or(""); - if dir_str.is_empty() { - String::new() - } else { - format!("{}/", dir_str) - } + // Reconstruct the value to insert (keep the directory prefix typed so far). + // Derive from `partial` (before `~`-expansion) so completions stay in the + // form the user typed (e.g. `~/` rather than `/home/user/`). + let dir_prefix: &str = match partial.rfind('/') { + Some(pos) => &partial[..=pos], + None => "", }; let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); @@ -1018,10 +1019,35 @@ impl TuiApp { if partial.starts_with('@') { // File path completion after the '@' prefix. - let file_partial = &partial[1..]; - let items = complete_file_paths(file_partial); + // Support optional opening quote: @"path or @'path + let file_partial = &partial[1..]; // everything after '@' + let (quote, path_partial) = + if file_partial.starts_with('"') { + (Some('"'), &file_partial[1..]) + } else if file_partial.starts_with('\'') { + (Some('\''), &file_partial[1..]) + } else { + (None, file_partial) + }; + + let mut items = complete_file_paths(path_partial); + + // Wrap completion values in the same quote the user opened with. + // Files get a closing quote unless one already follows the cursor; + // directories never get a closing quote (allow continued navigation). + if let Some(q) = quote { + let closing_exists = first_line[cursor_col..].starts_with(q); + for item in &mut items { + if item.value.ends_with('/') || closing_exists { + item.value = format!("{}{}", q, item.value); + } else { + item.value = format!("{}{}{}", q, item.value, q); + } + } + } + if !items.is_empty() { - let file_start_col = arg_col + 1; + let file_start_col = arg_col + 1; // right after '@' let common_prefix_len = { let first = &items[0].value; items.iter().skip(1).fold(first.len(), |len, item| { @@ -1321,6 +1347,7 @@ impl TuiApp { } self.history.add(cmd.to_string()); if let Some(path) = arg.strip_prefix('@') { + let path = strip_outer_quotes(path); match read_file_content(path) { Ok(content) => match crate::query::try_split_queries(&content) { Some(queries) if !queries.is_empty() => { @@ -1348,6 +1375,7 @@ impl TuiApp { self.history.add(cmd.to_string()); let (n_runs, query_text) = parse_benchmark_args(cmd); let resolved = if let Some(path) = query_text.strip_prefix('@') { + let path = strip_outer_quotes(path); match read_file_content(path) { Ok(c) => c.trim().to_string(), Err(e) => { self.output.push_error(&e); return; } @@ -1362,6 +1390,7 @@ impl TuiApp { self.history.add(cmd.to_string()); let (interval_secs, query_text) = parse_watch_args(cmd); let resolved = if let Some(path) = query_text.strip_prefix('@') { + let path = strip_outer_quotes(path); match read_file_content(path) { Ok(c) => c.trim().to_string(), Err(e) => { self.output.push_error(&e); return; } @@ -2171,11 +2200,19 @@ impl TuiApp { .split(inner); // ── Search line (bottom) ──────────────────────────────────────────── + // Render the cursor by highlighting the character *under* it (or a + // space when at end-of-input), matching the style of the main editor. + let after_raw = hs.query_after_cursor(); + let (cursor_ch, tail): (String, &str) = if let Some(ch) = after_raw.chars().next() { + (ch.to_string(), &after_raw[ch.len_utf8()..]) + } else { + (" ".to_string(), "") + }; let search_line = Line::from(vec![ Span::styled("/ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::styled(hs.query_before_cursor().to_string(), Style::default().fg(Color::White)), - Span::styled("█", Style::default().fg(Color::Cyan)), - Span::styled(hs.query_after_cursor().to_string(), Style::default().fg(Color::White)), + Span::styled(cursor_ch, Style::default().fg(Color::Black).bg(Color::Cyan)), + Span::styled(tail.to_string(), Style::default().fg(Color::White)), ]); f.render_widget(Paragraph::new(search_line), chunks[1]); @@ -2552,4 +2589,19 @@ mod tests { assert_eq!(format_with_commas(1000), "1,000"); assert_eq!(format_with_commas(1234567), "1,234,567"); } + + #[test] + fn test_strip_outer_quotes() { + assert_eq!(strip_outer_quotes("bare"), "bare"); + assert_eq!(strip_outer_quotes("\"my file.sql\""), "my file.sql"); + assert_eq!(strip_outer_quotes("'my file.sql'"), "my file.sql"); + // Mismatched quotes → unchanged + assert_eq!(strip_outer_quotes("\"foo'"), "\"foo'"); + // Single-char string → unchanged (too short to strip) + assert_eq!(strip_outer_quotes("\""), "\""); + // Empty string → unchanged + assert_eq!(strip_outer_quotes(""), ""); + // Nested quotes: only outer layer stripped + assert_eq!(strip_outer_quotes("\"'inner'\""), "'inner'"); + } } From 5b9dd61fef227938f7229fd106f8ac38a914f051 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:13:04 +0100 Subject: [PATCH 090/147] Fix set/unset commands not persisting in TUI interactive mode set_args/unset_args mutated a cloned context inside the spawned task, so changes were discarded when the task ended. Apply them to self.context directly before cloning for the spawn so they persist across queries. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9bab895..e86aecb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -39,7 +39,7 @@ use crate::completion::SqlCompleter; use crate::context::Context; use crate::highlight::SqlHighlighter; use crate::meta_commands::handle_meta_command; -use crate::query::{query, try_split_queries}; +use crate::query::{query, set_args, try_split_queries, unset_args}; use crate::viewer::open_csvlens_viewer; use crate::CLI_VERSION; @@ -1842,6 +1842,14 @@ impl TuiApp { } async fn execute_queries(&mut self, original_text: String, queries: Vec) { + // Apply set/unset commands to self.context immediately so the changes + // persist across queries. The spawned task will apply them again on its + // cloned context (harmless due to BTreeMap deduplication in normalize_extras). + for q in &queries { + let _ = set_args(&mut self.context, q); + let _ = unset_args(&mut self.context, q); + } + // Echo query to output pane with syntax highlighting self.push_sql_echo(original_text.trim()); self.push_custom_settings(); From 1f5a1cf867a12cc86f2c702f01fbad26cfad9581 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:15:57 +0100 Subject: [PATCH 091/147] Improve error messages for non-2xx HTTP responses in streaming path In interactive/TUI mode the streaming JSONLines parser ran even on error responses (4xx/5xx), producing cryptic serde_json messages like "EOF while parsing an object at line 1 column 1" instead of the actual server error text. Fix: check the HTTP status before entering the parse loop. On non-success, read the body as plain text and emit it directly. Also include the raw line in verbose mode when a JSONLines parse error does occur. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/query.rs b/src/query.rs index 2d126de..eae0694 100644 --- a/src/query.rs +++ b/src/query.rs @@ -441,6 +441,20 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< // keep collecting all rows for csvlens. ──────────── use table_renderer::{ErrorDetail, JsonLineMessage, ParsedResult, ResultColumn}; + // Non-success responses won't be in JSONLines format. + // Collect the body as plain text and surface it directly. + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + let msg = body.trim(); + if msg.is_empty() { + out_err!(context, "Error: server returned HTTP {}", status.as_u16()); + } else { + out_err!(context, "Error: {}", msg); + } + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + error_kind = Some(kind); + } else { + let mut columns: Vec = Vec::new(); let mut display_rows: Vec> = Vec::new(); let mut all_rows: Vec> = Vec::new(); @@ -471,7 +485,15 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if line.is_empty() { continue; } match serde_json::from_str::(&line) { - Err(e) => { stream_err = Some(e.to_string()); break 'stream; } + Err(e) => { + let detail = if context.args.verbose { + format!("{} (raw: {:?})", e, line) + } else { + e.to_string() + }; + stream_err = Some(detail); + break 'stream; + } Ok(msg) => match msg { JsonLineMessage::Start { result_columns, .. } => { columns = result_columns; @@ -563,10 +585,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< context.last_stats = compute_stats(context.args.concise, &statistics); } - if !status.is_success() { - let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; - if error_kind != Some(ErrorKind::SystemError) { error_kind = Some(kind); } - } + } // end else (status.is_success()) } else { // ── Buffered path (non-interactive or server-rendered) ── let body = resp.text().await?; From bb0260dbcde947a132850ae7f82bd2d3300212d9 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:19:36 +0100 Subject: [PATCH 092/147] Fix streaming error display: show raw server line, not serde internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix intercepted all non-2xx responses before JSONLines parsing, which broke query error rendering — Firebolt returns HTTP 400 with a valid FINISH_WITH_ERRORS JSONLines body for bad SQL. Revised approach: - Always attempt JSONLines parsing regardless of HTTP status - When a line fails to parse as JsonLineMessage, show the raw line content (the actual server error text) instead of the serde_json internal error ("EOF while parsing an object at line 1 column 1") - In --verbose mode, append the parse error detail to the raw line - After the stream loop, add a fallback for non-2xx with empty/ unparseable body: "Error: server returned HTTP " Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/query.rs b/src/query.rs index eae0694..8b3a2cf 100644 --- a/src/query.rs +++ b/src/query.rs @@ -441,20 +441,6 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< // keep collecting all rows for csvlens. ──────────── use table_renderer::{ErrorDetail, JsonLineMessage, ParsedResult, ResultColumn}; - // Non-success responses won't be in JSONLines format. - // Collect the body as plain text and surface it directly. - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - let msg = body.trim(); - if msg.is_empty() { - out_err!(context, "Error: server returned HTTP {}", status.as_u16()); - } else { - out_err!(context, "Error: {}", msg); - } - let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; - error_kind = Some(kind); - } else { - let mut columns: Vec = Vec::new(); let mut display_rows: Vec> = Vec::new(); let mut all_rows: Vec> = Vec::new(); @@ -486,10 +472,12 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< match serde_json::from_str::(&line) { Err(e) => { + // Show the raw line (the actual server error text) + // rather than the internal serde parse error. let detail = if context.args.verbose { - format!("{} (raw: {:?})", e, line) + format!("{} (parse error: {})", line, e) } else { - e.to_string() + line.clone() }; stream_err = Some(detail); break 'stream; @@ -543,12 +531,14 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } if let Some(e) = stream_err { - if context.args.verbose { - out_err!(context, "Failed to read/parse response: {}", e); - } else { - out_err!(context, "Error: {}", e); - } - error_kind = Some(ErrorKind::SystemError); + out_err!(context, "Error: {}", e); + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + error_kind = Some(kind); + } else if !status.is_success() && errors.is_none() && columns.is_empty() { + // Non-2xx with an empty or unparseable body — show the HTTP status. + out_err!(context, "Error: server returned HTTP {}", status.as_u16()); + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + error_kind = Some(kind); } else if let Some(errs) = errors { for err in errs { out_err!(context, "Error: {}", err.description); @@ -585,7 +575,6 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< context.last_stats = compute_stats(context.args.concise, &statistics); } - } // end else (status.is_success()) } else { // ── Buffered path (non-interactive or server-rendered) ── let body = resp.text().await?; From 16426344029a649b3155ce9c65d533f1401ae300 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:21:13 +0100 Subject: [PATCH 093/147] Handle multi-line JSON error bodies in streaming path When the server sends a pretty-printed JSON error object (not JSONLines), the streaming loop only saw the first line '{', resulting in "Error: {". Fix: when a line fails to parse as JsonLineMessage, drain the remaining response chunks to get the complete body, then call readable_error() to extract a human-readable message. readable_error() checks common JSON error fields (error/message/description/detail) and falls back to compact JSON or raw text. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/query.rs b/src/query.rs index 8b3a2cf..4061309 100644 --- a/src/query.rs +++ b/src/query.rs @@ -7,6 +7,24 @@ use std::time::Instant; use tokio::{select, signal, task}; use tokio_util::sync::CancellationToken; +/// Extract a human-readable error message from a raw server response body. +/// +/// Tries to parse as JSON and look for common error fields; falls back to the +/// trimmed raw text. Accepts multi-line bodies (pretty-printed JSON objects). +fn readable_error(body: &str) -> String { + let trimmed = body.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + for field in &["error", "message", "description", "detail"] { + if let Some(s) = v.get(field).and_then(|v| v.as_str()) { + return s.to_string(); + } + } + // Valid JSON but no recognised error field — return compact form. + return serde_json::to_string(&v).unwrap_or_else(|_| trimmed.to_string()); + } + trimmed.to_string() +} + /// Distinguishes between user-caused and system/infrastructure failures. /// Values double as process exit codes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -471,15 +489,30 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if line.is_empty() { continue; } match serde_json::from_str::(&line) { - Err(e) => { - // Show the raw line (the actual server error text) - // rather than the internal serde parse error. - let detail = if context.args.verbose { - format!("{} (parse error: {})", line, e) + Err(parse_err) => { + // The line isn't a JSONLines message — the server + // likely sent a multi-line JSON error object. + // Drain the rest of the response so we have the + // complete body, then surface a readable message. + let mut body = line.clone(); + body.push('\n'); + body.push_str(&line_buf); + 'drain: loop { + match resp.chunk().await { + Ok(Some(c)) => { + if let Ok(s) = std::str::from_utf8(&c) { + body.push_str(s); + } + } + _ => break 'drain, + } + } + let msg = readable_error(&body); + stream_err = Some(if context.args.verbose { + format!("{} (parse error: {})", msg, parse_err) } else { - line.clone() - }; - stream_err = Some(detail); + msg + }); break 'stream; } Ok(msg) => match msg { From c6d664305ea348481881a81d1ffade082b528a48 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:22:26 +0100 Subject: [PATCH 094/147] readable_error: extract descriptions from nested errors array The server response {"errors":[{"description":"Unknown setting abc"}],...} was not matched by the top-level field check. Now also looks for errors[*].description and joins multiple messages with "; ". Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/query.rs b/src/query.rs index 4061309..46a87f2 100644 --- a/src/query.rs +++ b/src/query.rs @@ -14,11 +14,22 @@ use tokio_util::sync::CancellationToken; fn readable_error(body: &str) -> String { let trimmed = body.trim(); if let Ok(v) = serde_json::from_str::(trimmed) { + // Top-level string fields. for field in &["error", "message", "description", "detail"] { if let Some(s) = v.get(field).and_then(|v| v.as_str()) { return s.to_string(); } } + // `errors` array (e.g. {"errors":[{"description":"..."}]}). + if let Some(arr) = v.get("errors").and_then(|v| v.as_array()) { + let msgs: Vec<&str> = arr + .iter() + .filter_map(|e| e.get("description").and_then(|d| d.as_str())) + .collect(); + if !msgs.is_empty() { + return msgs.join("; "); + } + } // Valid JSON but no recognised error field — return compact form. return serde_json::to_string(&v).unwrap_or_else(|_| trimmed.to_string()); } From 355c023dde23a7f632f41a7e2efc0f498d10275f Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:31:19 +0100 Subject: [PATCH 095/147] Validate set commands with server before applying locally When the user types `set key = value`, send it to the server as SQL before updating the local context. If the server rejects it (unknown setting, bad value), show the error immediately at set-time instead of failing silently on the next query. - server_exec(): sends SET SQL, checks status and JSONLines FINISH_WITH_ERRORS, and captures the Firebolt-Update-Parameters response header as the authoritative command to apply (server's normalised form). - Client-side-only settings (format, completion) and localhost targets (Firebolt Core / local dev, which don't support SET as SQL) skip server validation and are applied directly. - TuiMsg::ApplyCmd propagates the validated set/unset command from the spawned query task back to TuiApp::context, replacing the previous pre-spawn application workaround. - unset commands also send ApplyCmd so TUI context stays in sync. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 101 +++++++++++++++++++++++++++++++++++++++++++++---- src/tui/mod.rs | 12 ++---- src/tui_msg.rs | 3 ++ 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/src/query.rs b/src/query.rs index 46a87f2..60a40ba 100644 --- a/src/query.rs +++ b/src/query.rs @@ -327,22 +327,109 @@ fn compute_stats(concise: bool, statistics: &Option) -> Optio }) } +/// Send a single SQL statement to the server for validation (used for SET commands). +/// +/// Returns: +/// - `Ok(Some(cmd))` — accepted; `cmd` is a `set key=value` string built from +/// the `Firebolt-Update-Parameters` response header (the server's authoritative form). +/// - `Ok(None)` — accepted but no update-params header; caller should apply the +/// original command. +/// - `Err(message)` — rejected; surface `message` to the user. +async fn server_exec(context: &Context, sql: &str) -> Result, String> { + let client = reqwest::Client::builder() + .build() + .map_err(|e| e.to_string())?; + + let mut req = client + .post(context.url.clone()) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) + .body(sql.to_string()); + + if let Some(sa_token) = &context.sa_token { + req = req.header("authorization", format!("Bearer {}", sa_token.token)); + } + if !context.args.jwt.is_empty() { + req = req.header("authorization", format!("Bearer {}", context.args.jwt)); + } + + let resp = req.send().await.map_err(|e| e.to_string())?; + + // Capture the server's authoritative form of the setting before consuming the body. + let update_params: Option = resp + .headers() + .get("firebolt-update-parameters") + .and_then(|v| v.to_str().ok()) + .map(|s| format!("set {}", s)); + + let status = resp.status(); + let body = resp.text().await.map_err(|e| e.to_string())?; + + if !status.is_success() { + return Err(readable_error(&body)); + } + + // Also check for query-level errors in a JSONLines body. + if let Ok(parsed) = table_renderer::parse_jsonlines_compact(&body) { + if let Some(errors) = parsed.errors { + if !errors.is_empty() { + let msgs: Vec<&str> = errors.iter().map(|e| e.description.as_str()).collect(); + return Err(msgs.join("; ")); + } + } + } + + Ok(update_params) +} + // Send query and print result. pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box> { - // Handle set/unset commands - if set_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii && !context.is_tui() { - out_err!(context, "URL: {}", context.url); - } + // Handle set commands: validate with server for non-client-side settings, + // then apply locally and propagate to the TUI via ApplyCmd. + { + static SET_KEY_RE: Lazy = Lazy::new(|| { + Regex::new(r#"(?i)^(?:--[^\n]*\n|/\*[\s\S]*\*/|[ \t\n])*set +([^ =]+) *="#).unwrap() + }); + if let Some(caps) = SET_KEY_RE.captures(&query_text) { + let key = caps.get(1).unwrap().as_str(); + let is_client_only = key.eq_ignore_ascii_case("format") + || key.eq_ignore_ascii_case("completion"); + + // Skip server validation for localhost targets (Firebolt Core / local dev) + // because they don't support SET as SQL syntax. + let skip_validation = is_client_only + || context.args.host.starts_with("localhost"); + + let cmd_to_apply: String = if skip_validation { + query_text.clone() + } else { + match server_exec(context, &query_text).await { + Ok(maybe_norm) => maybe_norm.unwrap_or_else(|| query_text.clone()), + Err(e) => { + out_err!(context, "Error: {}", e); + return Err(Box::new(QueryFailed(ErrorKind::QueryError))); + } + } + }; - return Ok(()); + set_args(context, &cmd_to_apply)?; + if !context.args.concise && !context.args.hide_pii && !context.is_tui() { + out_err!(context, "URL: {}", context.url); + } + if let Some(tx) = &context.tui_output_tx { + let _ = tx.send(crate::tui_msg::TuiMsg::ApplyCmd(cmd_to_apply)); + } + return Ok(()); + } } if unset_args(context, &query_text)? { if !context.args.concise && !context.args.hide_pii && !context.is_tui() { out_err!(context, "URL: {}", context.url); } - + if let Some(tx) = &context.tui_output_tx { + let _ = tx.send(crate::tui_msg::TuiMsg::ApplyCmd(query_text)); + } return Ok(()); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e86aecb..91e2eeb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -532,6 +532,10 @@ impl TuiApp { Ok(TuiMsg::Progress(n)) => { self.progress_rows = n; } + Ok(TuiMsg::ApplyCmd(cmd)) => { + let _ = set_args(&mut self.context, &cmd); + let _ = unset_args(&mut self.context, &cmd); + } Ok(TuiMsg::ParsedResult(result)) => { if result.columns.is_empty() { self.pending_schema_refresh = true; @@ -1842,14 +1846,6 @@ impl TuiApp { } async fn execute_queries(&mut self, original_text: String, queries: Vec) { - // Apply set/unset commands to self.context immediately so the changes - // persist across queries. The spawned task will apply them again on its - // cloned context (harmless due to BTreeMap deduplication in normalize_extras). - for q in &queries { - let _ = set_args(&mut self.context, q); - let _ = unset_args(&mut self.context, q); - } - // Echo query to output pane with syntax highlighting self.push_sql_echo(original_text.trim()); self.push_custom_settings(); diff --git a/src/tui_msg.rs b/src/tui_msg.rs index dd35217..681b143 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -10,6 +10,9 @@ pub enum TuiMsg { ParsedResult(crate::table_renderer::ParsedResult), /// Overwrite the running-pane hint line (e.g. benchmark run progress). RunHint(String), + /// A `set key=value` or `unset key` command that was accepted by the server + /// and should now be applied to the main TUI context. + ApplyCmd(String), } /// A single rendered line made up of zero or more styled spans. From 0c070dec232577aed6a3ffc9d8d5fabbc1a87b79 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:36:24 +0100 Subject: [PATCH 096/147] Revert "Validate set commands with server before applying locally" This reverts commit 355c023dde23a7f632f41a7e2efc0f498d10275f. --- src/query.rs | 101 ++++--------------------------------------------- src/tui/mod.rs | 12 ++++-- src/tui_msg.rs | 3 -- 3 files changed, 15 insertions(+), 101 deletions(-) diff --git a/src/query.rs b/src/query.rs index 60a40ba..46a87f2 100644 --- a/src/query.rs +++ b/src/query.rs @@ -327,109 +327,22 @@ fn compute_stats(concise: bool, statistics: &Option) -> Optio }) } -/// Send a single SQL statement to the server for validation (used for SET commands). -/// -/// Returns: -/// - `Ok(Some(cmd))` — accepted; `cmd` is a `set key=value` string built from -/// the `Firebolt-Update-Parameters` response header (the server's authoritative form). -/// - `Ok(None)` — accepted but no update-params header; caller should apply the -/// original command. -/// - `Err(message)` — rejected; surface `message` to the user. -async fn server_exec(context: &Context, sql: &str) -> Result, String> { - let client = reqwest::Client::builder() - .build() - .map_err(|e| e.to_string())?; - - let mut req = client - .post(context.url.clone()) - .header("user-agent", USER_AGENT) - .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) - .body(sql.to_string()); - - if let Some(sa_token) = &context.sa_token { - req = req.header("authorization", format!("Bearer {}", sa_token.token)); - } - if !context.args.jwt.is_empty() { - req = req.header("authorization", format!("Bearer {}", context.args.jwt)); - } - - let resp = req.send().await.map_err(|e| e.to_string())?; - - // Capture the server's authoritative form of the setting before consuming the body. - let update_params: Option = resp - .headers() - .get("firebolt-update-parameters") - .and_then(|v| v.to_str().ok()) - .map(|s| format!("set {}", s)); - - let status = resp.status(); - let body = resp.text().await.map_err(|e| e.to_string())?; - - if !status.is_success() { - return Err(readable_error(&body)); - } - - // Also check for query-level errors in a JSONLines body. - if let Ok(parsed) = table_renderer::parse_jsonlines_compact(&body) { - if let Some(errors) = parsed.errors { - if !errors.is_empty() { - let msgs: Vec<&str> = errors.iter().map(|e| e.description.as_str()).collect(); - return Err(msgs.join("; ")); - } - } - } - - Ok(update_params) -} - // Send query and print result. pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box> { - // Handle set commands: validate with server for non-client-side settings, - // then apply locally and propagate to the TUI via ApplyCmd. - { - static SET_KEY_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^(?:--[^\n]*\n|/\*[\s\S]*\*/|[ \t\n])*set +([^ =]+) *="#).unwrap() - }); - if let Some(caps) = SET_KEY_RE.captures(&query_text) { - let key = caps.get(1).unwrap().as_str(); - let is_client_only = key.eq_ignore_ascii_case("format") - || key.eq_ignore_ascii_case("completion"); - - // Skip server validation for localhost targets (Firebolt Core / local dev) - // because they don't support SET as SQL syntax. - let skip_validation = is_client_only - || context.args.host.starts_with("localhost"); - - let cmd_to_apply: String = if skip_validation { - query_text.clone() - } else { - match server_exec(context, &query_text).await { - Ok(maybe_norm) => maybe_norm.unwrap_or_else(|| query_text.clone()), - Err(e) => { - out_err!(context, "Error: {}", e); - return Err(Box::new(QueryFailed(ErrorKind::QueryError))); - } - } - }; - - set_args(context, &cmd_to_apply)?; - if !context.args.concise && !context.args.hide_pii && !context.is_tui() { - out_err!(context, "URL: {}", context.url); - } - if let Some(tx) = &context.tui_output_tx { - let _ = tx.send(crate::tui_msg::TuiMsg::ApplyCmd(cmd_to_apply)); - } - return Ok(()); + // Handle set/unset commands + if set_args(context, &query_text)? { + if !context.args.concise && !context.args.hide_pii && !context.is_tui() { + out_err!(context, "URL: {}", context.url); } + + return Ok(()); } if unset_args(context, &query_text)? { if !context.args.concise && !context.args.hide_pii && !context.is_tui() { out_err!(context, "URL: {}", context.url); } - if let Some(tx) = &context.tui_output_tx { - let _ = tx.send(crate::tui_msg::TuiMsg::ApplyCmd(query_text)); - } + return Ok(()); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 91e2eeb..e86aecb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -532,10 +532,6 @@ impl TuiApp { Ok(TuiMsg::Progress(n)) => { self.progress_rows = n; } - Ok(TuiMsg::ApplyCmd(cmd)) => { - let _ = set_args(&mut self.context, &cmd); - let _ = unset_args(&mut self.context, &cmd); - } Ok(TuiMsg::ParsedResult(result)) => { if result.columns.is_empty() { self.pending_schema_refresh = true; @@ -1846,6 +1842,14 @@ impl TuiApp { } async fn execute_queries(&mut self, original_text: String, queries: Vec) { + // Apply set/unset commands to self.context immediately so the changes + // persist across queries. The spawned task will apply them again on its + // cloned context (harmless due to BTreeMap deduplication in normalize_extras). + for q in &queries { + let _ = set_args(&mut self.context, q); + let _ = unset_args(&mut self.context, q); + } + // Echo query to output pane with syntax highlighting self.push_sql_echo(original_text.trim()); self.push_custom_settings(); diff --git a/src/tui_msg.rs b/src/tui_msg.rs index 681b143..dd35217 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -10,9 +10,6 @@ pub enum TuiMsg { ParsedResult(crate::table_renderer::ParsedResult), /// Overwrite the running-pane hint line (e.g. benchmark run progress). RunHint(String), - /// A `set key=value` or `unset key` command that was accepted by the server - /// and should now be applied to the main TUI context. - ApplyCmd(String), } /// A single rendered line made up of zero or more styled spans. From c890f7c913a40358f7f44d387b5bfcf68f95604c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:42:20 +0100 Subject: [PATCH 097/147] Validate set commands with SELECT 1 before applying locally For server-side parameters (anything that modifies args.extra), send a SELECT 1 with the new setting to verify the server accepts it before persisting the change. Local-only settings (format, completion) and unset commands skip validation. On rejection, echo the set statement and show the server's error message. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 35 +++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 25 +++++++++++++++++++------ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/query.rs b/src/query.rs index 46a87f2..dfe3226 100644 --- a/src/query.rs +++ b/src/query.rs @@ -258,6 +258,41 @@ pub async fn query_silent(context: &mut Context, query_text: &str) -> Result Result<(), String> { + let client = reqwest::Client::builder() + .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) + .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) + .http2_keep_alive_while_idle(false) + .tcp_keepalive(Some(std::time::Duration::from_secs(60))) + .build() + .map_err(|e| e.to_string())?; + + let req = client + .post(context.url.clone()) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) + .header("Firebolt-Machine-Query", "true") + .body("SELECT 1;"); + + let req = if let Some(sa_token) = &context.sa_token { + req.header("authorization", format!("Bearer {}", sa_token.token)) + } else if !context.args.jwt.is_empty() { + req.header("authorization", format!("Bearer {}", context.args.jwt)) + } else { + req + }; + + let response = req.send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(readable_error(&body)); + } + Ok(()) +} + /// Render a result table to a String using the display mode from context. fn render_table_output( context: &Context, diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e86aecb..74fd3d4 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -39,7 +39,7 @@ use crate::completion::SqlCompleter; use crate::context::Context; use crate::highlight::SqlHighlighter; use crate::meta_commands::handle_meta_command; -use crate::query::{query, set_args, try_split_queries, unset_args}; +use crate::query::{query, set_args, try_split_queries, unset_args, validate_setting}; use crate::viewer::open_csvlens_viewer; use crate::CLI_VERSION; @@ -1842,12 +1842,25 @@ impl TuiApp { } async fn execute_queries(&mut self, original_text: String, queries: Vec) { - // Apply set/unset commands to self.context immediately so the changes - // persist across queries. The spawned task will apply them again on its - // cloned context (harmless due to BTreeMap deduplication in normalize_extras). + // Apply set/unset commands to self.context immediately. For set commands + // that change server-side parameters (i.e. modify args.extra), first validate + // them by sending SELECT 1 with the new settings; bail out on rejection. for q in &queries { - let _ = set_args(&mut self.context, q); - let _ = unset_args(&mut self.context, q); + let extra_before = self.context.args.extra.clone(); + let mut test_ctx = self.context.clone(); + if set_args(&mut test_ctx, q).unwrap_or(false) { + if test_ctx.args.extra != extra_before { + // Server-side parameter: validate before applying + if let Err(e) = validate_setting(&mut test_ctx).await { + self.push_sql_echo(q.trim()); + self.output.push_error(&format!("Error: {e}")); + return; + } + } + let _ = set_args(&mut self.context, q); + } else { + let _ = unset_args(&mut self.context, q); + } } // Echo query to output pane with syntax highlighting From 406f1e7b1e976ab6c62b268b51dbeba348461381 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 15:52:59 +0100 Subject: [PATCH 098/147] Fix all compiler warnings Remove unused imports, delete dead code where safe, suppress with #[allow(dead_code)] where the code is part of an unfinished subsystem (ANSI highlighter, schema cache, context detector). Remove needless mut bindings and unused variables. Co-Authored-By: Claude Sonnet 4.6 --- src/args.rs | 5 +---- src/completion/candidates.rs | 4 ++-- src/completion/context_analyzer.rs | 1 + src/completion/context_detector.rs | 5 ++++- src/completion/fuzzy_completer.rs | 1 + src/completion/mod.rs | 3 +++ src/completion/schema_cache.rs | 15 +++++++++++---- src/completion/usage_tracker.rs | 1 + src/highlight.rs | 9 +++++++-- src/query.rs | 1 + src/sql_parser.rs | 2 ++ src/tui/completion_popup.rs | 1 + src/tui/history_search.rs | 1 + src/tui/mod.rs | 5 ++--- src/tui/output_pane.rs | 2 ++ src/tui/signature_hint.rs | 1 + 16 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/args.rs b/src/args.rs index a16541f..0e9b0ca 100644 --- a/src/args.rs +++ b/src/args.rs @@ -2,7 +2,6 @@ use gumdrop::Options; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; -use std::io::IsTerminal; use crate::utils::{config_path, init_root_path}; @@ -30,9 +29,6 @@ fn default_max_cell_length() -> usize { 1000 } -fn default_true() -> bool { - true -} fn default_cache_ttl() -> u64 { 300 @@ -174,6 +170,7 @@ impl Args { self.get_display_mode().eq_ignore_ascii_case("auto") } + #[allow(dead_code)] /// Determine if colors should be used for syntax highlighting pub fn should_use_colors(&self) -> bool { // Check NO_COLOR environment variable (standard: no-color.org) diff --git a/src/completion/candidates.rs b/src/completion/candidates.rs index 2e61eb5..a73f1ff 100644 --- a/src/completion/candidates.rs +++ b/src/completion/candidates.rs @@ -425,8 +425,8 @@ mod tests { #[test] fn test_collect_priority_column_in_query_beats_table() { - let cache = Arc::new(super::super::schema_cache::SchemaCache::new(300)); - let usage_tracker = Arc::new(UsageTracker::new(10)); + let _cache = Arc::new(super::super::schema_cache::SchemaCache::new(300)); + let _usage_tracker = Arc::new(UsageTracker::new(10)); // Empty cache — just verify priority constants are ordered correctly assert!(PRIORITY_COLUMN_IN_QUERY > PRIORITY_PUBLIC_TABLE); assert!(PRIORITY_PUBLIC_TABLE > PRIORITY_SCHEMA); diff --git a/src/completion/context_analyzer.rs b/src/completion/context_analyzer.rs index 16f4946..5452509 100644 --- a/src/completion/context_analyzer.rs +++ b/src/completion/context_analyzer.rs @@ -73,6 +73,7 @@ impl ContextAnalyzer { } } + #[allow(dead_code)] /// Extract the table name from a qualified column reference /// E.g., "users.user_id" -> Some("users") pub fn extract_table_from_qualified_column(qualified_column: &str) -> Option { diff --git a/src/completion/context_detector.rs b/src/completion/context_detector.rs index 42ccac3..cbe3b92 100644 --- a/src/completion/context_detector.rs +++ b/src/completion/context_detector.rs @@ -1,11 +1,13 @@ use once_cell::sync::Lazy; use regex::Regex; +#[allow(dead_code)] static KEYWORD_PATTERN: Lazy = Lazy::new(|| { Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|FULL|OUTER|ON|GROUP|ORDER|BY|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|DROP|ALTER|TABLE|DATABASE|INDEX|VIEW|AS|DISTINCT|ALL|AND|OR|NOT|IN|EXISTS|BETWEEN|LIKE|IS|NULL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE)\b").unwrap() }); #[derive(Debug, PartialEq, Clone)] +#[allow(dead_code)] pub enum CompletionContext { Keyword, // Start of statement or after whitespace TableName, // After FROM, JOIN, INTO, UPDATE @@ -16,6 +18,7 @@ pub enum CompletionContext { } /// Detects what type of completion is appropriate at the given position +#[allow(dead_code)] pub fn detect_context(line: &str, pos: usize) -> CompletionContext { // Clamp position to line length let pos = pos.min(line.len()); @@ -53,7 +56,7 @@ pub fn detect_context(line: &str, pos: usize) -> CompletionContext { Some("SELECT") => { // After SELECT, if we've seen other tokens (like *), we might be typing FROM // Check if there are non-keyword tokens after SELECT - let after_select = context_before + let _after_select = context_before .split("SELECT") .last() .unwrap_or("") diff --git a/src/completion/fuzzy_completer.rs b/src/completion/fuzzy_completer.rs index ce7b224..8c77820 100644 --- a/src/completion/fuzzy_completer.rs +++ b/src/completion/fuzzy_completer.rs @@ -92,6 +92,7 @@ pub struct FuzzyItem { /// Short description matching tab-completion labels: "table" / "column" / "function" / "schema". pub description: String, /// The logical type. + #[allow(dead_code)] pub item_type: ItemType, /// Text to insert into the textarea when accepted. pub insert_value: String, diff --git a/src/completion/mod.rs b/src/completion/mod.rs index f75f4f6..865e233 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -22,6 +22,7 @@ pub struct CompletionItem { /// Short description shown in the popup (type label or schema name). pub description: String, /// The logical type of the item. + #[allow(dead_code)] pub item_type: ItemType, } @@ -47,10 +48,12 @@ impl SqlCompleter { self.enabled = enabled; } + #[allow(dead_code)] pub fn is_enabled(&self) -> bool { self.enabled } + #[allow(dead_code)] pub fn cache(&self) -> &Arc { &self.cache } diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index f3bd392..f365234 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -12,11 +12,13 @@ pub struct TableMetadata { } #[derive(Clone)] +#[allow(dead_code)] pub struct ColumnMetadata { pub name: String, pub data_type: String, } +#[allow(dead_code)] pub struct SchemaCache { tables: Arc>>, functions: Arc>>, @@ -61,6 +63,7 @@ impl SchemaCache { } /// Checks if cache is stale based on TTL + #[allow(dead_code)] pub fn is_stale(&self) -> bool { let last_refresh = self.last_refresh.read().unwrap(); match *last_refresh { @@ -86,6 +89,7 @@ impl SchemaCache { } /// Get all keywords matching prefix + #[allow(dead_code)] pub fn get_keywords(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_lowercase(); self.keywords @@ -96,6 +100,7 @@ impl SchemaCache { } /// Get all table names matching prefix + #[allow(dead_code)] pub fn get_tables(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_lowercase(); let tables = self.tables.read().unwrap(); @@ -113,6 +118,7 @@ impl SchemaCache { } /// Get all column names matching prefix + #[allow(dead_code)] pub fn get_columns(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_lowercase(); let tables = self.tables.read().unwrap(); @@ -129,6 +135,7 @@ impl SchemaCache { } /// Get all unique schema names matching prefix + #[allow(dead_code)] pub fn get_schemas(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_lowercase(); let tables = self.tables.read().unwrap(); @@ -147,6 +154,7 @@ impl SchemaCache { } /// Get all function names matching prefix + #[allow(dead_code)] pub fn get_functions(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_lowercase(); let functions = self.functions.read().unwrap(); @@ -183,6 +191,7 @@ impl SchemaCache { /// Get all table names with their schemas matching prefix /// Returns Vec<(schema, table)> + #[allow(dead_code)] pub fn get_tables_with_schema(&self, prefix: &str) -> Vec<(String, String)> { let prefix_lower = prefix.to_lowercase(); let tables = self.tables.read().unwrap(); @@ -240,6 +249,7 @@ impl SchemaCache { /// Get the table for a given column name /// Returns the first table that contains this column + #[allow(dead_code)] pub fn get_table_for_column(&self, column_name: &str) -> Option { let tables = self.tables.read().unwrap(); let column_lower = column_name.to_lowercase(); @@ -306,6 +316,7 @@ impl SchemaCache { } /// Synchronous method to get completions from cache + #[allow(dead_code)] pub fn get_completions( &self, context: super::context_detector::CompletionContext, @@ -509,16 +520,12 @@ impl SchemaCache { } // Update tables cache - let num_tables = new_tables.len(); - let num_columns: usize = new_tables.values().map(|t| t.columns.len()).sum(); *self.tables.write().unwrap() = new_tables; // Parse functions - let mut num_functions = 0; match functions_result { Ok(functions_output) => { if let Some(function_list) = Self::parse_functions(&functions_output) { - num_functions = function_list.len(); *self.functions.write().unwrap() = function_list.into_iter().collect(); } else { eprintln!("Warning: Failed to parse functions from schema query"); diff --git a/src/completion/usage_tracker.rs b/src/completion/usage_tracker.rs index f30ee71..1ea28f5 100644 --- a/src/completion/usage_tracker.rs +++ b/src/completion/usage_tracker.rs @@ -83,6 +83,7 @@ impl UsageTracker { } } + #[allow(dead_code)] /// Check if an item name appears in any recent query pub fn was_used_recently(&self, name: &str) -> bool { let recent = self.recent_queries.read().unwrap(); diff --git a/src/highlight.rs b/src/highlight.rs index 02593e7..6ea9f3b 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1,9 +1,10 @@ use once_cell::sync::Lazy; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Color, Style}; use regex::Regex; use std::ops::Range; /// Color scheme using ANSI escape codes (for headless/non-TUI output) +#[allow(dead_code)] pub struct ColorScheme { keyword: &'static str, function: &'static str, @@ -67,6 +68,7 @@ static LINE_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"--[^\n]*"). static BLOCK_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"/\*[\s\S]*?\*/").unwrap()); // Operator pattern +#[allow(dead_code)] static OPERATOR_PATTERN: Lazy = Lazy::new(|| Regex::new(r"[=<>!]+|[+\-*/%]|\|\||::").unwrap()); /// Holds a non-overlapping list of (start, end, priority) spans. @@ -95,7 +97,7 @@ impl SpanList { /// Collect non-overlapping byte ranges → (start, end, priority), sorted by start. /// When multiple spans overlap the same byte, the highest priority wins. - fn into_sorted_ranges(mut self) -> Vec<(usize, usize, u8)> { + fn into_sorted_ranges(self) -> Vec<(usize, usize, u8)> { if self.entries.is_empty() { return vec![]; } @@ -139,6 +141,7 @@ fn prio_to_ratatui_style(prio: u8) -> Style { } } +#[allow(dead_code)] fn prio_to_ansi_color<'a>(prio: u8, scheme: &'a ColorScheme) -> &'a str { match prio { PRIO_COMMENT => scheme.comment, @@ -185,6 +188,7 @@ fn compute_ranges(text: &str) -> Vec<(Range, u8)> { /// SQL syntax highlighter. /// Produces ANSI-escaped strings for headless display and ratatui `Style` spans for TUI. pub struct SqlHighlighter { + #[allow(dead_code)] color_scheme: ColorScheme, enabled: bool, } @@ -198,6 +202,7 @@ impl SqlHighlighter { } /// Highlight SQL text by applying ANSI color codes (headless / non-TUI mode). + #[allow(dead_code)] pub fn highlight_sql(&self, line: &str) -> String { if !self.enabled || line.is_empty() { return line.to_string(); diff --git a/src/query.rs b/src/query.rs index dfe3226..45d946e 100644 --- a/src/query.rs +++ b/src/query.rs @@ -131,6 +131,7 @@ const INTERACTIVE_MAX_BYTES: usize = 1_048_576; // 1 MB // Limit rows for interactive display, returning a slice and an optional truncation message. // Bytes are estimated as the sum of JSON string lengths across all cells in a row. +#[allow(dead_code)] fn apply_output_limits(rows: &[Vec]) -> (&[Vec], Option) { let mut byte_count = 0usize; for (i, row) in rows.iter().enumerate() { diff --git a/src/sql_parser.rs b/src/sql_parser.rs index 1816ddd..e192bc9 100644 --- a/src/sql_parser.rs +++ b/src/sql_parser.rs @@ -4,10 +4,12 @@ /// Each component that needs parsing should call `create_parser()` to get its /// own `Parser` instance — `tree_sitter::Parser` is not `Sync`. +#[allow(dead_code)] pub fn sql_language() -> tree_sitter::Language { devgen_tree_sitter_sql::language() } +#[allow(dead_code)] pub fn create_parser() -> tree_sitter::Parser { let mut parser = tree_sitter::Parser::new(); parser diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index c5e4aa5..485a2bf 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -12,6 +12,7 @@ use ratatui::{ use crate::completion::CompletionItem; /// State for an open completion popup session. +#[allow(dead_code)] pub struct CompletionState { /// All available completion items. pub items: Vec, diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs index a49e345..0c76e3e 100644 --- a/src/tui/history_search.rs +++ b/src/tui/history_search.rs @@ -35,6 +35,7 @@ impl HistorySearch { s } + #[allow(dead_code)] pub fn query(&self) -> &str { &self.query } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 74fd3d4..10ca7a6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -41,7 +41,6 @@ use crate::highlight::SqlHighlighter; use crate::meta_commands::handle_meta_command; use crate::query::{query, set_args, try_split_queries, unset_args, validate_setting}; use crate::viewer::open_csvlens_viewer; -use crate::CLI_VERSION; use completion_popup::CompletionState; use fuzzy_popup::FuzzyState; @@ -411,7 +410,7 @@ impl TuiApp { } fn make_textarea() -> TextArea<'static> { - let mut ta = TextArea::default(); + let ta = TextArea::default(); // Disable the current-line underline — terminal underlines inherit the // foreground colour, which would make syntax-highlighted lines look // inconsistent (each token segment would draw its underline in a @@ -1922,7 +1921,7 @@ impl TuiApp { fn set_textarea_content(&mut self, content: &str) { let lines: Vec = content.lines().map(|l| l.to_string()).collect(); - let mut ta = TextArea::new(if lines.is_empty() { + let ta = TextArea::new(if lines.is_empty() { vec![String::new()] } else { lines diff --git a/src/tui/output_pane.rs b/src/tui/output_pane.rs index 4737551..d632f1e 100644 --- a/src/tui/output_pane.rs +++ b/src/tui/output_pane.rs @@ -52,6 +52,7 @@ impl OutputPane { self.scroll_to_bottom(); } + #[allow(dead_code)] /// Push the echoed prompt line (`❯ SELECT …`). /// The `❯ ` prefix is rendered in green+bold; the SQL text in yellow. /// Continuation lines (indented with spaces) are rendered fully in yellow. @@ -69,6 +70,7 @@ impl OutputPane { self.scroll_to_bottom(); } + #[allow(dead_code)] /// Push the echoed prompt line with syntax highlighting. /// /// `prefix` is `"❯ "` for the first line or `" "` for continuation lines. diff --git a/src/tui/signature_hint.rs b/src/tui/signature_hint.rs index 1c72c6a..944ae87 100644 --- a/src/tui/signature_hint.rs +++ b/src/tui/signature_hint.rs @@ -71,6 +71,7 @@ pub fn detect_function_at_cursor(sql: &str, cursor: usize) -> Option { stack.into_iter().rev().find_map(|x| x) } +#[allow(dead_code)] /// The byte offset in `sql` where the currently-active function name starts, /// or `None`. Used to position the popup horizontally. pub fn func_name_start(sql: &str, cursor: usize) -> Option { From 9df7d99a54af22edfb05b9f1ebb9fa50f6cc026b Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 16:06:55 +0100 Subject: [PATCH 099/147] Use new TUI renderer in headless mode; remove comfy-table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old comfy-table-based render_table / render_table_vertical with render_table_plain, which calls the same TUI line renderer used in interactive mode and converts TuiLines to plain Unicode text. Output now uses box-drawing characters (┌─┐│╞═╡) in all modes. Remove: comfy-table dependency, render_table, render_table_vertical, should_use_vertical_mode, and the tests that covered them. Update integration tests to check for │ instead of +/|, and relax the wide-table test since vertical/horizontal layout depends on terminal width at runtime. Update README: client:auto is the default in all modes. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 139 ++++----------- Cargo.toml | 1 - README.md | 2 + src/args.rs | 1 + src/query.rs | 34 ++-- src/table_renderer.rs | 381 ------------------------------------------ tests/cli.rs | 12 +- 7 files changed, 60 insertions(+), 510 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58f44a1..ba54b92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,12 +324,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -434,7 +428,7 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.114", @@ -461,18 +455,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "comfy-table" -version = "6.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e959d788268e3bf9d35ace83e81b124190378e4c91c9067524675e33394b8ba" -dependencies = [ - "crossterm 0.26.1", - "strum 0.24.1", - "strum_macros 0.24.3", - "unicode-width 0.1.14", -] - [[package]] name = "compact_str" version = "0.8.1" @@ -532,32 +514,16 @@ dependencies = [ "libc", ] -[[package]] -name = "crossterm" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" -dependencies = [ - "bitflags 1.3.2", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", "filedescriptor", - "mio 1.1.1", + "mio", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -622,7 +588,7 @@ dependencies = [ "arboard", "arrow", "clap", - "crossterm 0.28.1", + "crossterm", "csv", "qsv-sniffer", "ratatui", @@ -715,7 +681,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags", "objc2", ] @@ -789,8 +755,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" name = "fb" version = "0.2.3" dependencies = [ - "comfy-table", - "crossterm 0.28.1", + "crossterm", "csvlens", "devgen-tree-sitter-sql", "dirs", @@ -1026,12 +991,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1442,7 +1401,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", ] @@ -1506,18 +1465,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.1.1" @@ -1665,7 +1612,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -1677,7 +1624,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags", "dispatch2", "objc2", ] @@ -1688,7 +1635,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags", "dispatch2", "objc2", "objc2-core-foundation", @@ -1707,7 +1654,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags", "objc2", "objc2-core-foundation", ] @@ -1718,7 +1665,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags", "objc2", "objc2-core-foundation", ] @@ -1741,7 +1688,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1948,7 +1895,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b25b79fc637d5ec0a9d72612207f48676b39c8b5c48ab32cfa0d47915fd664a" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytecount", "csv", "csv-core", @@ -1990,16 +1937,16 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools", "lru", "paste", - "strum 0.26.3", + "strum", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -2011,7 +1958,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -2114,7 +2061,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2127,7 +2074,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.11.0", @@ -2200,7 +2147,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -2328,8 +2275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.1.1", + "mio", "signal-hook", ] @@ -2405,32 +2351,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" - [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", + "strum_macros", ] [[package]] @@ -2439,7 +2366,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -2500,7 +2427,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -2546,7 +2473,7 @@ dependencies = [ "cfg-if", "libc", "memchr", - "mio 1.1.1", + "mio", "terminal-trx", "windows-sys 0.61.2", "xterm-color", @@ -2650,7 +2577,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio 1.1.1", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -2765,7 +2692,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", "http", @@ -2841,7 +2768,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74f679521b7fd35e17fbca58ec5aac64c5d331e54a9034510ec26b193ffd7597" dependencies = [ - "crossterm 0.28.1", + "crossterm", "ratatui", "unicode-width 0.2.0", ] @@ -2852,7 +2779,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ - "crossterm 0.28.1", + "crossterm", "ratatui", "unicode-width 0.2.0", ] @@ -3066,7 +2993,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags 2.10.0", + "bitflags", "rustix 1.1.3", "wayland-backend", "wayland-scanner", @@ -3078,7 +3005,7 @@ version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -3090,7 +3017,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", diff --git a/Cargo.toml b/Cargo.toml index 52b7f85..911ee6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ urlencoding = "2.1" pest = "2.7" pest_derive = "2.7" terminal_size = "0.3" -comfy-table = "6.2" csvlens = "0.14" devgen-tree-sitter-sql = "0.21.0" tree-sitter = "0.21.0" diff --git a/README.md b/README.md index d8fee9f..19ba1bb 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ Time: 15.2ms Override with `--format client:vertical` or set at runtime: `set format = client:vertical;` +The default format is `client:auto` in all modes (interactive REPL, single-query, and pipe mode). + ### Server-Side Rendering Pass any Firebolt output format name (without a `client:` prefix) to receive raw server-rendered output: diff --git a/src/args.rs b/src/args.rs index 0e9b0ca..4879dd8 100644 --- a/src/args.rs +++ b/src/args.rs @@ -166,6 +166,7 @@ impl Args { self.get_display_mode().eq_ignore_ascii_case("horizontal") } + #[allow(dead_code)] pub fn is_auto_display(&self) -> bool { self.get_display_mode().eq_ignore_ascii_case("auto") } diff --git a/src/query.rs b/src/query.rs index 45d946e..893ff6c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -295,26 +295,28 @@ pub async fn validate_setting(context: &mut Context) -> Result<(), String> { } /// Render a result table to a String using the display mode from context. -fn render_table_output( +/// Render a result table as a plain-text String using the same renderer as the +/// TUI, so headless output matches the interactive REPL visually. +fn render_table_plain( context: &Context, columns: &[table_renderer::ResultColumn], rows: &[Vec], terminal_width: u16, max_cell_length: usize, ) -> String { - if context.args.is_horizontal_display() { - table_renderer::render_table(columns, rows, max_cell_length) - } else if context.args.is_vertical_display() { - table_renderer::render_table_vertical(columns, rows, terminal_width, max_cell_length) - } else if context.args.is_auto_display() { - if table_renderer::should_use_vertical_mode(columns, terminal_width, context.args.min_col_width) { - table_renderer::render_table_vertical(columns, rows, terminal_width, max_cell_length) - } else { - table_renderer::render_table(columns, rows, max_cell_length) - } + let tw = terminal_width.saturating_sub(1); + let lines = if context.args.is_vertical_display() { + table_renderer::render_vertical_table_to_tui_lines(columns, rows, tw, max_cell_length) + } else if context.args.is_horizontal_display() { + table_renderer::render_horizontal_forced_tui_lines(columns, rows, tw, max_cell_length) } else { - table_renderer::render_table(columns, rows, max_cell_length) - } + table_renderer::render_table_to_tui_lines(columns, rows, tw, max_cell_length) + }; + lines + .into_iter() + .map(|line| line.0.into_iter().map(|s| s.text).collect::()) + .collect::>() + .join("\n") } /// In TUI mode, emit a result table as pre-styled TuiLines (no ANSI round-trip). @@ -577,7 +579,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if context.is_tui() { emit_table_tui(context, &columns, &display_rows, terminal_width, max_cell); } else { - let rendered = render_table_output(context, &columns, &display_rows, terminal_width, max_cell); + let rendered = render_table_plain(context, &columns, &display_rows, terminal_width, max_cell); out!(context, "{}", rendered); } out_err!(context, "Showing first {} rows — collecting remainder for Ctrl+V / /view...", @@ -632,7 +634,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if context.is_tui() { emit_table_tui(context, &columns, &all_rows, terminal_width, max_cell); } else { - let rendered = render_table_output(context, &columns, &all_rows, terminal_width, max_cell); + let rendered = render_table_plain(context, &columns, &all_rows, terminal_width, max_cell); out!(context, "{}", rendered); } } else { @@ -681,7 +683,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if context.is_tui() { emit_table_tui(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); } else { - let table_output = render_table_output(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); + let table_output = render_table_plain(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); out!(context, "{}", table_output); } context.last_stats = compute_stats(context.args.concise, &parsed.statistics); diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 433aa03..0c47d0f 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -1,8 +1,6 @@ use crate::tui_msg::{TuiColor, TuiLine, TuiSpan}; -use comfy_table::{Attribute, Cell, Color, ColumnConstraint, ContentArrangement, Table, Width as ComfyWidth}; use serde::Deserialize; use serde_json::Value; -use terminal_size::{terminal_size, Width}; #[derive(Debug, Deserialize)] #[serde(tag = "message_type")] @@ -89,65 +87,6 @@ pub fn parse_jsonlines_compact(text: &str) -> Result], max_value_length: usize) -> String { - let mut table = Table::new(); - - // Enable dynamic content arrangement for automatic wrapping - table.set_content_arrangement(ContentArrangement::Dynamic); - - // Detect terminal width and calculate equal column widths - let terminal_width = terminal_size().map(|(Width(w), _)| w).unwrap_or(80); - - table.set_width(terminal_width); - - // Calculate equal column width if we have columns - let num_columns = columns.len(); - if num_columns > 0 { - // Subtract 4 for outer table borders, then divide equally - let available_width = terminal_width.saturating_sub(4); - let col_width = available_width / num_columns as u16; - - // Set explicit column constraints for equal widths - let constraints: Vec = (0..num_columns) - .map(|_| ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(col_width))) - .collect(); - table.set_constraints(constraints); - } - - // Add headers with styling - let header_cells: Vec = columns - .iter() - .map(|col| Cell::new(&col.name).fg(Color::Cyan).add_attribute(Attribute::Bold)) - .collect(); - - table.set_header(header_cells); - - // Add data rows - for row in rows { - let row_cells: Vec = row - .iter() - .map(|val| { - let value_str = format_value(val); - // Truncate strings exceeding max_value_length - let display_value = if value_str.len() > max_value_length { - format!("{}...", &value_str[..max_value_length.saturating_sub(3)]) - } else { - value_str - }; - // Color NULL values differently to distinguish from string "NULL" - if val.is_null() { - Cell::new(display_value).fg(Color::DarkGrey) - } else { - Cell::new(display_value) - } - }) - .collect(); - - table.add_row(row_cells); - } - - table.to_string() -} /// Format a serde_json::Value for table display. /// @@ -193,78 +132,6 @@ fn format_value(value: &Value) -> String { } } -/// Calculate the display width of a string, ignoring ANSI escape codes -/// Render table in vertical format (two-column table with column names and values) -/// Used when table is too wide for horizontal display in auto mode -pub fn render_table_vertical(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> String { - let mut output = String::new(); - - for (row_idx, row) in rows.iter().enumerate() { - // Row header - output.push_str(&format!("Row {}:\n", row_idx + 1)); - - // Create a two-column table for this row - let mut table = Table::new(); - table.set_content_arrangement(ContentArrangement::Dynamic); - - // Set column constraints to allow wrapping - // First column (names): narrow, fixed - // Second column (values): wide, allows wrapping - let available_width = if terminal_width > 10 { terminal_width - 4 } else { 76 }; - table.set_constraints(vec![ - ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(30)), // Column names - ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(available_width.saturating_sub(30))), // Values - ]); - - // Add rows (no header - just column name | value pairs) - for (col_idx, col) in columns.iter().enumerate() { - if col_idx < row.len() { - let value = format_value(&row[col_idx]); - - // Truncate long values - let truncated_value = if value.len() > max_value_length { - format!("{}...", &value[..max_value_length]) - } else { - value - }; - - // Column name cell (cyan, bold) - let name_cell = Cell::new(&col.name).fg(Color::Cyan).add_attribute(Attribute::Bold); - - // Value cell - color NULL values differently to distinguish from string "NULL" - let value_cell = if row[col_idx].is_null() { - Cell::new(truncated_value).fg(Color::DarkGrey) - } else { - Cell::new(truncated_value) - }; - - table.add_row(vec![name_cell, value_cell]); - } - } - - output.push_str(&table.to_string()); - - // Blank line between rows (except after last row) - if row_idx < rows.len() - 1 { - output.push('\n'); - output.push('\n'); - } - } - - output -} - -pub fn should_use_vertical_mode(columns: &[ResultColumn], terminal_width: u16, min_col_width: usize) -> bool { - let num_columns = columns.len(); - - if num_columns == 0 { - return false; - } - - // Simple logic: switch to vertical if each column has less than min_col_width chars available - let chars_per_column = (terminal_width as usize) / num_columns; - chars_per_column < min_col_width -} /// Format a serde_json::Value for CSV export. /// @@ -926,104 +793,6 @@ mod tests { assert_eq!(format_value(&Value::String("test".to_string())), "test"); } - #[test] - fn test_render_vertical_single_row() { - let columns = vec![ - ResultColumn { - name: "id".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "name".to_string(), - column_type: "text".to_string(), - }, - ResultColumn { - name: "status".to_string(), - column_type: "text".to_string(), - }, - ]; - let rows = vec![vec![ - Value::Number(1.into()), - Value::String("Alice".to_string()), - Value::String("active".to_string()), - ]]; - - let output = render_table_vertical(&columns, &rows, 80, 1000); - - // Check for row header - assert!(output.contains("Row 1:")); - - // Check for table format (contains column names) - assert!(output.contains("id")); - assert!(output.contains("name")); - assert!(output.contains("status")); - - // Check for values - assert!(output.contains("1")); - assert!(output.contains("Alice")); - assert!(output.contains("active")); - - // Should have table borders - assert!(output.contains('+')); - assert!(output.contains('|')); - } - - #[test] - fn test_render_vertical_multiple_rows() { - let columns = vec![ - ResultColumn { - name: "id".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "name".to_string(), - column_type: "text".to_string(), - }, - ]; - let rows = vec![ - vec![Value::Number(1.into()), Value::String("Alice".to_string())], - vec![Value::Number(2.into()), Value::String("Bob".to_string())], - ]; - - let output = render_table_vertical(&columns, &rows, 80, 1000); - - // Should have both row headers - assert!(output.contains("Row 1:")); - assert!(output.contains("Row 2:")); - - // Should have both names - assert!(output.contains("Alice")); - assert!(output.contains("Bob")); - - // Should have blank line between rows (two consecutive newlines between tables) - let parts: Vec<&str> = output.split("Row 2:").collect(); - assert!(parts.len() == 2); - // Check there's spacing before "Row 2:" - assert!(parts[0].ends_with("\n\n") || parts[0].ends_with("\n+")); - } - - #[test] - fn test_render_vertical_value_truncation() { - let columns = vec![ResultColumn { - name: "long_col".to_string(), - column_type: "text".to_string(), - }]; - let long_value = "a".repeat(2000); // 2000 characters - let rows = vec![vec![Value::String(long_value)]]; - - let output = render_table_vertical(&columns, &rows, 80, 1000); - - // Should be truncated to 1000 chars + "..." - assert!(output.contains("...")); - - // Value should not exceed max_value_length - let lines: Vec<&str> = output.lines().collect(); - for line in lines { - // Each line in the table shouldn't be excessively long - assert!(line.len() < 1100); // Some margin for table borders - } - } - #[test] fn test_json_truncation() { // Create a very large JSON array @@ -1036,103 +805,6 @@ mod tests { assert!(formatted.contains("truncated") || formatted.len() < 5000); } - #[test] - fn test_should_use_vertical_mode() { - let columns = vec![ - ResultColumn { - name: "col1".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "col2".to_string(), - column_type: "text".to_string(), - }, - ]; - - // Wide terminal, few columns -> horizontal - // 150 width / 2 columns = 75 chars per column >= 10, stay horizontal - assert!(!should_use_vertical_mode(&columns, 150, 10)); - - // Many columns -> vertical - // 150 width / 20 columns = 7.5 chars per column < 10, use vertical - let many_columns: Vec = (0..20) - .map(|i| ResultColumn { - name: format!("column_name_{}", i), - column_type: "int".to_string(), - }) - .collect(); - assert!(should_use_vertical_mode(&many_columns, 150, 10)); - - // Narrow terminal with few columns -> vertical - // 40 width / 5 columns = 8 chars per column < 10, use vertical - let five_columns = vec![ - ResultColumn { - name: "a".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "b".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "c".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "d".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "e".to_string(), - column_type: "int".to_string(), - }, - ]; - assert!(should_use_vertical_mode(&five_columns, 40, 10)); - - // Configurable threshold test - // 80 width / 10 columns = 8 chars per column - let ten_columns: Vec = (0..10) - .map(|i| ResultColumn { - name: format!("col{}", i), - column_type: "int".to_string(), - }) - .collect(); - // With threshold 8, should stay horizontal (8 >= 8) - assert!(!should_use_vertical_mode(&ten_columns, 80, 8)); - // With threshold 9, should switch to vertical (8 < 9) - assert!(should_use_vertical_mode(&ten_columns, 80, 9)); - } - - #[test] - fn test_vertical_mode_threshold() { - // Test that the decision is based purely on terminal_width / num_columns - let three_columns: Vec = (0..3) - .map(|i| ResultColumn { - name: format!("col{}", i), - column_type: "text".to_string(), - }) - .collect(); - - // 80 width / 3 columns = 26.6 chars per column >= 10, stay horizontal - assert!(!should_use_vertical_mode(&three_columns, 80, 10)); - - // But with a higher threshold of 30, should switch to vertical (26.6 < 30) - assert!(should_use_vertical_mode(&three_columns, 80, 30)); - - // Edge case: exactly at threshold - let eight_columns: Vec = (0..8) - .map(|i| ResultColumn { - name: format!("c{}", i), - column_type: "int".to_string(), - }) - .collect(); - // 80 width / 8 columns = 10 chars per column - // Should stay horizontal (10 >= 10) - assert!(!should_use_vertical_mode(&eight_columns, 80, 10)); - // Should switch to vertical (10 < 11) - assert!(should_use_vertical_mode(&eight_columns, 80, 11)); - } - #[test] fn test_write_result_as_csv() { let columns = vec![ @@ -1180,57 +852,4 @@ mod tests { assert!(csv_str.contains("\"has\nnewline\"")); } - #[test] - fn test_null_rendering() { - // Test that NULL values and string "NULL" are both rendered correctly - // (Color distinction can be verified manually; tests just ensure no crashes) - let columns = vec![ - ResultColumn { - name: "id".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "name".to_string(), - column_type: "text".to_string(), - }, - ]; - let rows = vec![ - vec![Value::Number(1.into()), Value::Null], // Real NULL - vec![Value::Number(2.into()), Value::String("NULL".to_string())], // String "NULL" - vec![Value::Number(3.into()), Value::String("test".to_string())], // Regular string - ]; - - // Should not crash and should contain NULL text - let output = render_table(&columns, &rows, 1000); - assert!(output.contains("NULL")); - assert!(output.contains("test")); - assert!(output.contains('1')); - assert!(output.contains('2')); - assert!(output.contains('3')); - } - - #[test] - fn test_null_rendering_vertical() { - // Test that NULL values are rendered in vertical mode without crashes - let columns = vec![ - ResultColumn { - name: "id".to_string(), - column_type: "int".to_string(), - }, - ResultColumn { - name: "value".to_string(), - column_type: "text".to_string(), - }, - ]; - let rows = vec![ - vec![Value::Number(1.into()), Value::Null], - vec![Value::Number(2.into()), Value::String("NULL".to_string())], - ]; - - // Should not crash and should contain NULL text - let output = render_table_vertical(&columns, &rows, 80, 1000); - assert!(output.contains("NULL")); - assert!(output.contains("id")); - assert!(output.contains("value")); - } } diff --git a/tests/cli.rs b/tests/cli.rs index 81c93e3..ceabe90 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -320,7 +320,7 @@ fn test_expanded_format() { #[test] fn test_wide_table_auto_expanded() { - // Query with many columns should automatically use vertical mode + // Query with many columns — layout depends on terminal width, just verify data is present let (success, stdout, _) = run_fb(&[ "--core", "--format=client:auto", @@ -328,7 +328,8 @@ fn test_wide_table_auto_expanded() { 7 as g, 8 as h, 9 as i, 10 as j, 11 as k, 12 as l, 13 as m", ]); assert!(success); - assert!(stdout.contains("Row 1:")); // Should auto-switch to vertical + assert!(stdout.contains('a') && stdout.contains('m')); // Column headers present + assert!(stdout.contains('1') && stdout.contains("13")); // Values present } #[test] @@ -349,8 +350,7 @@ fn test_client_format_horizontal() { assert!(stdout.contains("id")); assert!(stdout.contains("name")); assert!(stdout.contains("test")); - assert!(stdout.contains('+')); // Has borders - assert!(stdout.contains('|')); // Has column separators + assert!(stdout.contains('│')); // Has column separators (Unicode box-drawing) // Should NOT use vertical format assert!(!stdout.contains("Row 1")); @@ -374,7 +374,7 @@ fn test_client_format_auto() { assert!(success); // Should have table format - assert!(stdout.contains('+')); // Has table borders + assert!(stdout.contains('│')); // Has table borders (Unicode box-drawing) assert!(stdout.contains("id")); } @@ -443,7 +443,7 @@ fn test_default_format_is_client_auto() { // Without --format, output should use client:auto (bordered table) let (success, stdout, _) = run_fb(&["--core", "SELECT 1 as id"]); assert!(success); - assert!(stdout.contains('+'), "default format should produce table borders (client:auto)"); + assert!(stdout.contains('│'), "default format should produce table borders (client:auto)"); assert!(stdout.contains("id")); } From b7e44da3608e60a599ab413bc5c3fafed3e31449 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 16:12:37 +0100 Subject: [PATCH 100/147] Clean up non-interactive output: no URL, stats on stdout for client-side format - Remove URL printing for set/unset and server-updated-URL in headless mode (only --verbose still shows URLs) - Client-side format in headless: emit Time/Scanned/Request Id to stdout right after the table, matching TUI output-pane behaviour - Server-side format: keep Time/Scanned/Request Id on stderr (scripting) - Update test to reflect the new stdout/stderr split Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 40 +++++++++++++++++++++++----------------- tests/cli.rs | 18 ++++++++++++------ 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/query.rs b/src/query.rs index 893ff6c..4bc2690 100644 --- a/src/query.rs +++ b/src/query.rs @@ -369,18 +369,10 @@ fn compute_stats(concise: bool, statistics: &Option) -> Optio pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box> { // Handle set/unset commands if set_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii && !context.is_tui() { - out_err!(context, "URL: {}", context.url); - } - return Ok(()); } if unset_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii && !context.is_tui() { - out_err!(context, "URL: {}", context.url); - } - return Ok(()); } @@ -497,7 +489,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< updated_url = true; } } - if updated_url && !context.args.concise && !context.args.hide_pii && !context.is_tui() { + if updated_url && context.args.verbose && !context.args.hide_pii { out_err!(context, "URL: {}", context.url); } @@ -718,16 +710,30 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if !context.args.concise { let elapsed = format!("{:?}", elapsed / 100000 * 100000); - out_err!(context, "Time: {elapsed}"); - if let Some(stats) = &context.last_stats { - out_err!(context, "{}", stats); - } - if let Some(request_id) = maybe_request_id { - if !context.is_tui() { - out_err!(context, "Request Id: {request_id}"); + if context.args.should_render_table() && !context.is_tui() { + // Client-side format in headless: emit to stdout so stats + // follow the table (mirrors TUI output-pane behaviour). + out!(context, "Time: {elapsed}"); + if let Some(stats) = &context.last_stats { + out!(context, "{}", stats); + } + if let Some(request_id) = maybe_request_id { + out!(context, "Request Id: {request_id}"); + } + out!(context, ""); + } else { + // Server-side format or TUI: keep on stderr. + out_err!(context, "Time: {elapsed}"); + if let Some(stats) = &context.last_stats { + out_err!(context, "{}", stats); + } + if let Some(request_id) = maybe_request_id { + if !context.is_tui() { + out_err!(context, "Request Id: {request_id}"); + } } + context.emit_newline(); } - context.emit_newline(); } } }; diff --git a/tests/cli.rs b/tests/cli.rs index ceabe90..f4d6873 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -460,15 +460,21 @@ fn test_stats_on_stderr_not_stdout() { #[test] fn test_concise_suppresses_stats() { - // Without --concise: stats on stderr - let (success, _, stderr) = run_fb(&["--core", "SELECT 1"]); + // Client-side format (default): stats go to stdout (mirrors TUI behaviour) + let (success, stdout, _) = run_fb(&["--core", "SELECT 1"]); assert!(success); - assert!(stderr.contains("Time:"), "without --concise, stderr should contain timing"); + assert!(stdout.contains("Time:"), "client-side format: timing should be on stdout"); - // With --concise: no stats - let (success, _, stderr) = run_fb(&["--core", "--concise", "SELECT 1"]); + // Server-side format: stats go to stderr (scripting-friendly) + let (success, stdout, stderr) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 1"]); assert!(success); - assert!(!stderr.contains("Time:"), "--concise should suppress timing stats"); + assert!(!stdout.contains("Time:"), "server-side format: timing should not be on stdout"); + assert!(stderr.contains("Time:"), "server-side format: timing should be on stderr"); + + // With --concise: no stats anywhere + let (success, stdout, stderr) = run_fb(&["--core", "--concise", "SELECT 1"]); + assert!(success); + assert!(!stdout.contains("Time:") && !stderr.contains("Time:"), "--concise should suppress timing stats"); } // ── Scripting output formats ───────────────────────────────────────────────── From cfc85f85751f9b0cd5e12c5cbd3ff0eac5c3d3cf Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 16:20:24 +0100 Subject: [PATCH 101/147] Remove stats output for server-side formats in headless mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side formats (PSQL, JSON, CSV, etc.) now produce no timing, scan stats, or request ID in headless mode — stdout is raw server output only. Stats remain visible in the TUI for all formats. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 9 ++------- tests/cli.rs | 20 ++++++++++++-------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/query.rs b/src/query.rs index 4bc2690..23502b1 100644 --- a/src/query.rs +++ b/src/query.rs @@ -721,17 +721,12 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< out!(context, "Request Id: {request_id}"); } out!(context, ""); - } else { - // Server-side format or TUI: keep on stderr. + } else if context.is_tui() { + // TUI with server-side format: send stats to output pane. out_err!(context, "Time: {elapsed}"); if let Some(stats) = &context.last_stats { out_err!(context, "{}", stats); } - if let Some(request_id) = maybe_request_id { - if !context.is_tui() { - out_err!(context, "Request Id: {request_id}"); - } - } context.emit_newline(); } } diff --git a/tests/cli.rs b/tests/cli.rs index f4d6873..5341006 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -224,8 +224,8 @@ fn test_json_output_fully_parseable() { assert!(success); - // stderr should contain stats - assert!(stderr.contains("Time:"), "stderr should contain Time:"); + // server-side format: no stats on either stream + assert!(!stderr.contains("Time:") && !stdout.contains("Time:"), "server-side format: no timing stats"); // stdout should be valid JSON Lines - each non-empty line should be valid JSON let trimmed_stdout = stdout.trim(); @@ -451,11 +451,16 @@ fn test_default_format_is_client_auto() { #[test] fn test_stats_on_stderr_not_stdout() { - // Results go to stdout, timing stats go to stderr — critical for scripting + // Server-side format: no stats on either stream (clean output for scripting) let (success, stdout, stderr) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42"]); assert!(success); - assert!(!stdout.contains("Time:"), "stdout should not contain timing stats"); - assert!(stderr.contains("Time:"), "stderr should contain timing stats"); + assert!(!stdout.contains("Time:"), "server-side format: no timing on stdout"); + assert!(!stderr.contains("Time:"), "server-side format: no timing on stderr"); + + // Client-side format: stats follow the table on stdout + let (success, stdout, _) = run_fb(&["--core", "SELECT 42"]); + assert!(success); + assert!(stdout.contains("Time:"), "client-side format: timing should be on stdout"); } #[test] @@ -465,11 +470,10 @@ fn test_concise_suppresses_stats() { assert!(success); assert!(stdout.contains("Time:"), "client-side format: timing should be on stdout"); - // Server-side format: stats go to stderr (scripting-friendly) + // Server-side format: no stats at all (clean output for scripting) let (success, stdout, stderr) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 1"]); assert!(success); - assert!(!stdout.contains("Time:"), "server-side format: timing should not be on stdout"); - assert!(stderr.contains("Time:"), "server-side format: timing should be on stderr"); + assert!(!stdout.contains("Time:") && !stderr.contains("Time:"), "server-side format: no timing stats"); // With --concise: no stats anywhere let (success, stdout, stderr) = run_fb(&["--core", "--concise", "SELECT 1"]); From 88411bec2c1e12832492820d82502284650e3455 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 16:31:37 +0100 Subject: [PATCH 102/147] Remove --concise and --no-spinner; spinner follows format mode - Remove --concise and --no-spinner flags entirely - Spinner now shown in non-interactive mode only for client-side formats (mirroring when stats are shown); auth spinner follows same rule - Stats always shown for client-side formats, never for server-side - ^C cancellation message removed; system engine hint TUI-only - README updated: scripting examples, stdout/stderr section, flags list - Tests updated: remove all --concise usage, delete redundant test Co-Authored-By: Claude Sonnet 4.6 --- README.md | 28 ++++++++--------- src/args.rs | 9 ------ src/auth.rs | 7 +++-- src/query.rs | 86 ++++++++++++++++++++++------------------------------ tests/cli.rs | 43 ++++++++------------------ 5 files changed, 66 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 19ba1bb..80b17a0 100644 --- a/README.md +++ b/README.md @@ -268,17 +268,17 @@ Saved defaults are stored in `~/.firebolt/fb_config` and merged with any flags y ### stdout vs stderr -Query results are always written to **stdout**. Timing statistics and error messages are written to **stderr**. You can redirect them independently: +Query results are always written to **stdout**. Error messages are written to **stderr**. Timing statistics follow the table on **stdout** for client-side formats; server-side formats produce no timing output. You can redirect them independently: ```bash -# Save results to a file, see stats in the terminal +# Save raw CSV (no stats) fb --core --format CSV "SELECT * FROM my_table" > results.csv -# Suppress stats entirely -fb --core --format CSV --concise "SELECT * FROM my_table" > results.csv +# Save client-side table + stats together +fb --core "SELECT * FROM my_table" > results.csv -# Capture results and stats separately -fb --core --format CSV "SELECT * FROM my_table" > results.csv 2> stats.txt +# Save only results, discard stats (stderr) +fb --core "SELECT * FROM my_table" > results.csv 2>/dev/null ``` ### JSON output @@ -286,18 +286,18 @@ fb --core --format CSV "SELECT * FROM my_table" > results.csv 2> stats.txt Use `JSON_Compact` for structured output that is easy to process with tools like `jq`: ```bash -fb --core --format JSON_Compact --concise "SELECT 1 AS n, 'hello' AS msg" +fb --core --format JSON_Compact "SELECT 1 AS n, 'hello' AS msg" # {"meta":[{"name":"n","type":"int"},{"name":"msg","type":"text"}],"data":[[1,"hello"]],...} # Extract with jq -fb --core --format JSON_Compact --concise "SELECT count(*) AS n FROM my_table" \ +fb --core --format JSON_Compact "SELECT count(*) AS n FROM my_table" \ | jq '.data[0][0]' ``` Use `JSONLines_Compact` for streaming-friendly line-delimited JSON (one message per line): ```bash -fb --core --format JSONLines_Compact --concise "SELECT 42 AS value" \ +fb --core --format JSONLines_Compact "SELECT 42 AS value" \ | grep '^{"message_type":"data"' \ | jq '.data[0][0]' ``` @@ -316,9 +316,9 @@ esac ```bash # Fail fast on any error set -e -fb --core --concise "INSERT INTO log SELECT now(), 'start';" -fb --core --concise "SELECT count(*) FROM my_table;" -fb --core --concise "INSERT INTO log SELECT now(), 'done';" +fb --core "INSERT INTO log SELECT now(), 'start';" +fb --core "SELECT count(*) FROM my_table;" +fb --core "INSERT INTO log SELECT now(), 'done';" ``` ### Pipe mode @@ -330,7 +330,7 @@ When stdin is not a terminal, fb reads queries line-by-line. All queries are exe echo "SELECT 1;" echo "SELECT 2;" echo "SELECT 3;" -} | fb --core --format TabSeparatedWithNamesAndTypes --concise +} | fb --core --format TabSeparatedWithNamesAndTypes ``` ## Firebolt Core @@ -386,9 +386,7 @@ Optional arguments: --jwt-from-file Load JWT from ~/.firebolt/jwt --oauth-env ENV OAuth environment: app or staging (default: staging) -v, --verbose Verbose output (shows URL, query text) - --concise Suppress timing statistics --hide-pii Hide URLs containing query parameters - --no-spinner Disable the query spinner --no-color Disable syntax highlighting --no-completion Disable tab completion --completion-cache-ttl SECS Schema cache TTL in seconds (default: 300) diff --git a/src/args.rs b/src/args.rs index 4879dd8..27603f2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -92,18 +92,10 @@ pub struct Args { #[serde(default)] pub verbose: bool, - #[options(no_short, help = "Suppress time statistics in output")] - #[serde(default)] - pub concise: bool, - #[options(no_short, help = "Hide URLs that may contain PII in query parameters")] #[serde(default)] pub hide_pii: bool, - #[options(no_short, help = "Disable the spinner in CLI output")] - #[serde(default)] - pub no_spinner: bool, - #[options(no_short, help = "Minimum characters per column before switching to vertical mode", default = "15")] #[serde(default = "default_min_col_width")] pub min_col_width: usize, @@ -259,7 +251,6 @@ pub fn get_args() -> Result> { } args.verbose = args.verbose || defaults.verbose; - args.concise = args.concise || defaults.concise; args.hide_pii = args.hide_pii || defaults.hide_pii; // Use defaults for numeric settings if not specified diff --git a/src/auth.rs b/src/auth.rs index 43d634c..22cdcc9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,6 +15,7 @@ pub async fn authenticate_service_account(context: &mut Context) -> Result<(), B } } + let show_spinner = !context.is_tui() && context.args.should_render_table(); let args = &mut context.args; if args.sa_id.is_empty() { return Err("Missing Service Account ID (--sa-id)".into()); @@ -72,13 +73,13 @@ pub async fn authenticate_service_account(context: &mut Context) -> Result<(), B let async_resp = async_req.send(); let token = CancellationToken::new(); - let maybe_spin = if args.no_spinner || args.concise { - None - } else { + let maybe_spin = if show_spinner { let token_clone = token.clone(); Some(task::spawn(async { spin(token_clone).await; })) + } else { + None }; let response = async_resp.await; diff --git a/src/query.rs b/src/query.rs index 23502b1..8ea1213 100644 --- a/src/query.rs +++ b/src/query.rs @@ -197,7 +197,7 @@ pub fn set_args(context: &mut Context, query: &str) -> Result) -> Option { - if concise || statistics.is_none() { +fn compute_stats(statistics: &Option) -> Option { + if statistics.is_none() { return None; } statistics.as_ref().and_then(|stats| { @@ -411,20 +411,18 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let async_resp = request.send(); - let finish_token = CancellationToken::new(); - // Skip the spinner when running inside the TUI (it has its own progress indicator). - let maybe_spin = if context.is_tui() || context.args.no_spinner || context.args.concise { - None - } else { - let token_clone = finish_token.clone(); - Some(task::spawn(async { - spin(token_clone).await; - })) - }; - // Build a cancel future: prefer the TUI's CancellationToken, fall back to SIGINT. let cancel = context.query_cancel.clone(); + // Show spinner in non-interactive mode only for client-side formats. + let spin_token = CancellationToken::new(); + let maybe_spin = if !context.is_tui() && context.args.should_render_table() { + let t = spin_token.clone(); + Some(task::spawn(async move { spin(t).await; })) + } else { + None + }; + let mut error_kind: Option = None; select! { @@ -435,21 +433,10 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let _ = signal::ctrl_c().await; } } => { - finish_token.cancel(); - if let Some(spin) = maybe_spin { - spin.await?; - } - if !context.args.concise { - out_err!(context, "^C"); - } error_kind = Some(ErrorKind::SystemError); } response = async_resp => { let elapsed = start.elapsed(); - finish_token.cancel(); - if let Some(spin) = maybe_spin { - spin.await?; - } let mut maybe_request_id: Option = None; match response { @@ -646,7 +633,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< }; context.emit_parsed_result(&parsed_result); context.last_result = Some(parsed_result); - context.last_stats = compute_stats(context.args.concise, &statistics); + context.last_stats = compute_stats(&statistics); } } else { @@ -678,7 +665,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let table_output = render_table_plain(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); out!(context, "{}", table_output); } - context.last_stats = compute_stats(context.args.concise, &parsed.statistics); + context.last_stats = compute_stats(&parsed.statistics); } } Err(e) => { @@ -708,31 +695,34 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< }, }; - if !context.args.concise { - let elapsed = format!("{:?}", elapsed / 100000 * 100000); - if context.args.should_render_table() && !context.is_tui() { - // Client-side format in headless: emit to stdout so stats - // follow the table (mirrors TUI output-pane behaviour). - out!(context, "Time: {elapsed}"); - if let Some(stats) = &context.last_stats { - out!(context, "{}", stats); - } - if let Some(request_id) = maybe_request_id { - out!(context, "Request Id: {request_id}"); - } - out!(context, ""); - } else if context.is_tui() { - // TUI with server-side format: send stats to output pane. - out_err!(context, "Time: {elapsed}"); - if let Some(stats) = &context.last_stats { - out_err!(context, "{}", stats); - } - context.emit_newline(); + let elapsed = format!("{:?}", elapsed / 100000 * 100000); + if context.args.should_render_table() && !context.is_tui() { + // Client-side format in headless: emit to stdout so stats + // follow the table (mirrors TUI output-pane behaviour). + out!(context, "Time: {elapsed}"); + if let Some(stats) = &context.last_stats { + out!(context, "{}", stats); + } + if let Some(request_id) = maybe_request_id { + out!(context, "Request Id: {request_id}"); } + out!(context, ""); + } else if context.is_tui() { + // TUI: send stats to output pane. + out_err!(context, "Time: {elapsed}"); + if let Some(stats) = &context.last_stats { + out_err!(context, "{}", stats); + } + context.emit_newline(); } } }; + spin_token.cancel(); + if let Some(s) = maybe_spin { + let _ = s.await; + } + if let Some(kind) = error_kind { Err(Box::new(QueryFailed(kind))) } else { @@ -780,8 +770,6 @@ mod tests { let mut args = get_args().unwrap(); args.host = "localhost:8123".to_string(); args.database = "test_db".to_string(); - args.concise = true; // suppress output - let mut context = Context::new(args); let query_text = "select 42".to_string(); diff --git a/tests/cli.rs b/tests/cli.rs index 5341006..7bbf110 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -38,7 +38,7 @@ fn test_basic_query() { #[test] fn test_set_format() { // First set format to TSV - let (success, stdout, _) = run_fb(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42;"]); + let (success, stdout, _) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42;"]); assert!(success); assert_eq!(stdout, "?column?\nint\n42\n"); } @@ -78,7 +78,6 @@ fn test_params_escaping() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) .args(&[ "--core", - "--concise", "-f", "TabSeparatedWithNamesAndTypes", "-e", @@ -195,7 +194,7 @@ fn test_command_parsing() { #[test] fn test_exiting() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() @@ -249,7 +248,7 @@ fn test_json_output_fully_parseable() { #[test] fn test_exit_code_on_connection_error() { // Test that exit code is non-zero when server is not available - let (success, _, stderr) = run_fb(&["--host", "localhost:59999", "--concise", "SELECT 1"]); + let (success, _, stderr) = run_fb(&["--host", "localhost:59999", "SELECT 1"]); assert!(!success, "Exit code should be non-zero when connection fails"); assert!( @@ -262,7 +261,7 @@ fn test_exit_code_on_connection_error() { #[test] fn test_exit_code_on_query_error() { // Test that exit code is non-zero when query returns an error (e.g., syntax error) - let (success, stdout, stderr) = run_fb(&["--core", "--concise", "SELEC INVALID SYNTAX"]); + let (success, stdout, stderr) = run_fb(&["--core", "SELEC INVALID SYNTAX"]); assert!(!success, "Exit code should be non-zero when query fails"); // The server should return an error message in stdout or stderr @@ -278,7 +277,7 @@ fn test_exit_code_on_query_error() { fn test_exit_code_on_query_error_interactive() { // Test that exit code is non-zero when any query fails in interactive mode let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&["--core", "--concise"]) + .args(&["--core"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -403,13 +402,13 @@ fn test_server_format_psql() { #[test] fn test_exit_code_query_error_is_1() { - let (code, _, _) = run_fb_code(&["--core", "--concise", "SELEC INVALID SYNTAX"]); + let (code, _, _) = run_fb_code(&["--core", "SELEC INVALID SYNTAX"]); assert_eq!(code, 1, "bad SQL should exit with code 1, not {}", code); } #[test] fn test_exit_code_system_error_is_2() { - let (code, _, stderr) = run_fb_code(&["--host", "localhost:59999", "--concise", "SELECT 1"]); + let (code, _, stderr) = run_fb_code(&["--host", "localhost:59999", "SELECT 1"]); assert_eq!(code, 2, "connection error should exit with code 2, not {}; stderr: {}", code, stderr); } @@ -419,7 +418,7 @@ fn test_exit_code_system_error_is_2() { fn test_exit_command() { // 'exit' should work the same as 'quit' let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() @@ -463,29 +462,11 @@ fn test_stats_on_stderr_not_stdout() { assert!(stdout.contains("Time:"), "client-side format: timing should be on stdout"); } -#[test] -fn test_concise_suppresses_stats() { - // Client-side format (default): stats go to stdout (mirrors TUI behaviour) - let (success, stdout, _) = run_fb(&["--core", "SELECT 1"]); - assert!(success); - assert!(stdout.contains("Time:"), "client-side format: timing should be on stdout"); - - // Server-side format: no stats at all (clean output for scripting) - let (success, stdout, stderr) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 1"]); - assert!(success); - assert!(!stdout.contains("Time:") && !stderr.contains("Time:"), "server-side format: no timing stats"); - - // With --concise: no stats anywhere - let (success, stdout, stderr) = run_fb(&["--core", "--concise", "SELECT 1"]); - assert!(success); - assert!(!stdout.contains("Time:") && !stderr.contains("Time:"), "--concise should suppress timing stats"); -} - // ── Scripting output formats ───────────────────────────────────────────────── #[test] fn test_json_compact_output() { - let (success, stdout, _) = run_fb(&["--core", "--concise", "--format=JSON_Compact", "SELECT 1 AS n"]); + let (success, stdout, _) = run_fb(&["--core", "--format=JSON_Compact", "SELECT 1 AS n"]); assert!(success); let parsed: serde_json::Value = serde_json::from_str(stdout.trim()) .expect("JSON_Compact output should be valid JSON"); @@ -495,7 +476,7 @@ fn test_json_compact_output() { #[test] fn test_tsv_output() { - let (success, stdout, _) = run_fb(&["--core", "--concise", "--format=TabSeparatedWithNamesAndTypes", "SELECT 42 AS answer"]); + let (success, stdout, _) = run_fb(&["--core", "--format=TabSeparatedWithNamesAndTypes", "SELECT 42 AS answer"]); assert!(success); assert!(stdout.contains("answer"), "TabSeparatedWithNamesAndTypes should include header"); assert!(stdout.contains("42")); @@ -506,7 +487,7 @@ fn test_tsv_output() { #[test] fn test_pipe_mode_multiple_queries_in_order() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() @@ -532,7 +513,7 @@ fn test_pipe_mode_multiple_queries_in_order() { fn test_pipe_mode_continues_after_error() { // A failed query in the middle should not abort subsequent queries let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) From 11b1d4122b0cec99023fce028b0d682d5a6a296b Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 16:48:05 +0100 Subject: [PATCH 103/147] Add .setting = value syntax for client-side settings Client-only settings (format, completion) now use a dot-prefix syntax instead of the SQL SET command, which is reserved for server parameters: .format = client:auto -- set output format .format = JSON -- switch to server-side rendering .format = -- reset to default (client:auto) .completion = off -- disable tab completion .completion = on -- re-enable tab completion .format -- show current value Behaviour changes: - `set completion = ...` in the TUI shows a clear error redirecting to the dot syntax; in pipe mode it is not intercepted (sent to server) - `unset format` now resets to `client:auto` (was `PSQL`) - `set format = ...` still works for backward compat (both client: and server-side values); only the dot syntax is documented as canonical - Dot commands work in TUI (Enter handler) and non-interactive pipe mode - Viewer flash message updated to show `.format = client:auto` Co-Authored-By: Claude Sonnet 4.6 --- README.md | 30 ++++++++++++++----- src/main.rs | 16 ++++++++-- src/query.rs | 80 +++++++++++++++++++++++++++++++++++++++++--------- src/tui/mod.rs | 30 +++++++++++++++++-- 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 80b17a0..44abb7e 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,10 @@ Type these directly in the REPL (or pass with `-c`): | `/run ` | Execute an inline SQL query | | `/benchmark [N] @\|` | Benchmark a query: 1 warmup + N timed runs (default N=3) | | `/watch [N] @\|` | Re-run query every N seconds (default 5); `Ctrl+C` stops | -| `set key=value;` | Set a query parameter | +| `set key=value;` | Set a server-side query parameter | | `unset key;` | Remove a query parameter | +| `.format = value` | Set client output format (e.g. `client:auto`, `JSON`) | +| `.completion = on\|off` | Enable or disable tab completion | ### `@` syntax @@ -209,7 +211,7 @@ The default format is `client:auto`, which fetches results as JSON and renders t Time: 15.2ms ``` -Override with `--format client:vertical` or set at runtime: `set format = client:vertical;` +Override with `--format client:vertical` or set at runtime: `.format = client:vertical` The default format is `client:auto` in all modes (interactive REPL, single-query, and pipe mode). @@ -227,15 +229,29 @@ Common formats: `PSQL`, `JSON`, `JSON_Compact`, `JSONLines_Compact`, `CSV`, `CSV ### Changing Format at Runtime -```sql -set format = client:vertical; -- client-side vertical -set format = JSON; -- server-side JSON -unset format; -- reset to default (client:auto) +``` +.format = client:vertical -- client-side vertical +.format = JSON -- server-side JSON +.format = -- reset to default (client:auto) +``` + +## Client Settings + +Settings that only affect the CLI use the `.setting = value` syntax: + +``` +.format = client:auto -- client-side rendering (default) +.format = client:vertical -- always vertical layout +.format = JSON -- server-side JSON output +.completion = off -- disable tab completion +.completion = on -- re-enable tab completion +.format -- show current format +.completion -- show current completion state ``` ## Set and Unset -Change query parameters at runtime without restarting: +Change server-side query parameters at runtime without restarting: ```sql set database = my_db; diff --git a/src/main.rs b/src/main.rs index 185b997..90ad0f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ use auth::maybe_authenticate; use completion::schema_cache::SchemaCache; use completion::usage_tracker::UsageTracker; use context::Context; -use query::{ErrorKind, QueryFailed, query}; +use query::{ErrorKind, QueryFailed, dot_command, query}; use tui::TuiApp; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -95,7 +95,12 @@ async fn run() -> i32 { let queries = query::try_split_queries(&buffer).unwrap_or_default(); if !queries.is_empty() { for q in queries { - worst = worst.max(run_query(&mut context, q).await); + let dotcheck = q.trim().trim_end_matches(';').trim(); + if dot_command(&mut context, dotcheck) { + // client-side setting applied; nothing to send to server + } else { + worst = worst.max(run_query(&mut context, q).await); + } } buffer.clear(); } @@ -106,7 +111,12 @@ async fn run() -> i32 { let text = format!("{};", buffer.trim()); if let Some(queries) = query::try_split_queries(&text) { for q in queries { - worst = worst.max(run_query(&mut context, q).await); + let dotcheck = q.trim().trim_end_matches(';').trim(); + if dot_command(&mut context, dotcheck) { + // client-side setting applied + } else { + worst = worst.max(run_query(&mut context, q).await); + } } } } diff --git a/src/query.rs b/src/query.rs index 8ea1213..9ccb79a 100644 --- a/src/query.rs +++ b/src/query.rs @@ -162,6 +162,67 @@ fn apply_output_limits(rows: &[Vec]) -> (&[Vec bool { + static DOT_RE: Lazy = + Lazy::new(|| Regex::new(r"^\.(\w+)\s*(?:=\s*(.*?))?\s*;?\s*$").unwrap()); + + let line = line.trim(); + if !line.starts_with('.') { + return false; + } + + let caps = match DOT_RE.captures(line) { + Some(c) => c, + None => { + out_err!(context, "Invalid dot command: {}", line); + return true; + } + }; + + let key = caps.get(1).map_or("", |m| m.as_str()); + let has_eq = line.contains('='); + let value = caps.get(2).map_or("", |m| m.as_str().trim()); + + match key { + "format" => { + if !has_eq { + out_err!(context, "format = {}", context.args.format); + } else { + context.args.format = if value.is_empty() { + "client:auto".to_string() + } else { + value.to_string() + }; + context.update_url(); + } + } + "completion" => { + if !has_eq { + let status = if context.args.no_completion { "off" } else { "on" }; + out_err!(context, "completion = {}", status); + } else if value.is_empty() { + context.args.no_completion = false; // reset to default (enabled) + } else { + let val_lower = value.to_lowercase(); + if val_lower == "on" || val_lower == "true" || val_lower == "1" { + context.args.no_completion = false; + } else if val_lower == "off" || val_lower == "false" || val_lower == "0" { + context.args.no_completion = true; + } else { + out_err!(context, "Invalid value for .completion: '{}'. Use 'on' or 'off'.", value); + } + } + } + _ => { + out_err!(context, "Unknown client setting '.{}'. Available: .format, .completion", key); + } + } + true +} + // Set parameters via query pub fn set_args(context: &mut Context, query: &str) -> Result> { // set flag = value; @@ -179,15 +240,9 @@ pub fn set_args(context: &mut Context, query: &str) -> Result = vec![]; @@ -216,10 +271,7 @@ pub fn unset_args(context: &mut Context, query: &str) -> Result = Lazy::new(|| + Regex::new(r"(?i)^\s*set\s+completion\s*=").unwrap() + ); + if COMPLETION_SET_RE.is_match(q) { + self.push_sql_echo(q.trim()); + self.output.push_error("'set completion' is a client setting — use '.completion = on|off'"); + return; + } + } + let extra_before = self.context.args.extra.clone(); let mut test_ctx = self.context.clone(); if set_args(&mut test_ctx, q).unwrap_or(false) { From a1969a7901eddcbb9df977db29757c1980b14327 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:11:24 +0100 Subject: [PATCH 104/147] Dot-command improvements: TUI output fix, .format client-only, correct format list - Fix dot command errors corrupting TUI: route output through a temporary channel instead of falling back to eprintln! while in raw mode - .format now only accepts client:* formats; server-side formats require --format or set output_format=; at runtime - Tab completion for .format only suggests client:auto/vertical/horizontal - Add tab completion for dot commands (both key name and value) - Add dot_command_hint() for server validation failures on known client settings - Remove unsupported CSV and JSON formats from README, args help, and completion candidates; replace with correct list: PSQL, JSON_Compact, JSON_CompactLimited, JSONLines_Compact, TabSeparatedWithNamesAndTypes Co-Authored-By: Claude Sonnet 4.6 --- README.md | 23 ++++---- src/args.rs | 2 +- src/query.rs | 26 ++++----- src/tui/mod.rs | 148 +++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 159 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 44abb7e..1f438df 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Type these directly in the REPL (or pass with `-c`): | `/watch [N] @\|` | Re-run query every N seconds (default 5); `Ctrl+C` stops | | `set key=value;` | Set a server-side query parameter | | `unset key;` | Remove a query parameter | -| `.format = value` | Set client output format (e.g. `client:auto`, `JSON`) | +| `.format = value` | Set client output format (e.g. `client:auto`, `client:vertical`) | | `.completion = on\|off` | Enable or disable tab completion | ### `@` syntax @@ -221,20 +221,22 @@ Pass any Firebolt output format name (without a `client:` prefix) to receive raw ``` fb --format PSQL "SELECT 42" -fb --format JSON "SELECT 42" +fb --format JSON_Compact "SELECT 42" fb --format TabSeparatedWithNamesAndTypes "SELECT 42" ``` -Common formats: `PSQL`, `JSON`, `JSON_Compact`, `JSONLines_Compact`, `CSV`, `CSVWithNames`, `TabSeparatedWithNames`, `TabSeparatedWithNamesAndTypes`. +Supported formats: `PSQL`, `JSON_Compact`, `JSON_CompactLimited`, `JSONLines_Compact`, `TabSeparatedWithNamesAndTypes`. ### Changing Format at Runtime ``` .format = client:vertical -- client-side vertical -.format = JSON -- server-side JSON +.format = client:horizontal -- client-side horizontal .format = -- reset to default (client:auto) ``` +For server-side formats use `--format` on the command line or `set output_format=PSQL;` at runtime. + ## Client Settings Settings that only affect the CLI use the `.setting = value` syntax: @@ -242,7 +244,7 @@ Settings that only affect the CLI use the `.setting = value` syntax: ``` .format = client:auto -- client-side rendering (default) .format = client:vertical -- always vertical layout -.format = JSON -- server-side JSON output +.format = client:horizontal -- always horizontal layout .completion = off -- disable tab completion .completion = on -- re-enable tab completion .format -- show current format @@ -287,14 +289,14 @@ Saved defaults are stored in `~/.firebolt/fb_config` and merged with any flags y Query results are always written to **stdout**. Error messages are written to **stderr**. Timing statistics follow the table on **stdout** for client-side formats; server-side formats produce no timing output. You can redirect them independently: ```bash -# Save raw CSV (no stats) -fb --core --format CSV "SELECT * FROM my_table" > results.csv +# Save server-rendered output (no stats) +fb --core --format TabSeparatedWithNamesAndTypes "SELECT * FROM my_table" > results.tsv # Save client-side table + stats together -fb --core "SELECT * FROM my_table" > results.csv +fb --core "SELECT * FROM my_table" > results.txt # Save only results, discard stats (stderr) -fb --core "SELECT * FROM my_table" > results.csv 2>/dev/null +fb --core "SELECT * FROM my_table" > results.txt 2>/dev/null ``` ### JSON output @@ -393,7 +395,8 @@ Optional arguments: -h, --host HOSTNAME Hostname and port -d, --database DATABASE Database name -f, --format FORMAT Output format (client:auto, client:vertical, - client:horizontal, PSQL, JSON, CSV, ...) + client:horizontal, PSQL, JSON_Compact, + JSONLines_Compact, TabSeparatedWithNamesAndTypes, ...) -e, --extra NAME=VALUE Extra query parameters (repeatable) -l, --label LABEL Query label for tracking -j, --jwt JWT JWT token for authentication diff --git a/src/args.rs b/src/args.rs index 27603f2..5cbc483 100644 --- a/src/args.rs +++ b/src/args.rs @@ -52,7 +52,7 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub database: String, - #[options(help = "Output format (client:auto, client:vertical, client:horizontal, PSQL, JSON, CSV, ...)")] + #[options(help = "Output format (client:auto, client:vertical, client:horizontal, PSQL, JSON_Compact, JSONLines_Compact, TabSeparatedWithNamesAndTypes, ...)")] #[serde(skip_serializing, skip_deserializing)] pub format: String, diff --git a/src/query.rs b/src/query.rs index 9ccb79a..f19ee3b 100644 --- a/src/query.rs +++ b/src/query.rs @@ -177,7 +177,7 @@ pub fn dot_command(context: &mut Context, line: &str) -> bool { let caps = match DOT_RE.captures(line) { Some(c) => c, None => { - out_err!(context, "Invalid dot command: {}", line); + out_err!(context, "Error: invalid dot command: {}", line); return true; } }; @@ -190,12 +190,17 @@ pub fn dot_command(context: &mut Context, line: &str) -> bool { "format" => { if !has_eq { out_err!(context, "format = {}", context.args.format); + } else if value.is_empty() { + context.args.format = "client:auto".to_string(); + context.update_url(); + } else if !value.starts_with("client:") { + out_err!( + context, + "Error: .format only accepts client-side formats: client:auto, client:vertical, client:horizontal. \ + Use --format or 'set output_format=;' for server-side formats." + ); } else { - context.args.format = if value.is_empty() { - "client:auto".to_string() - } else { - value.to_string() - }; + context.args.format = value.to_string(); context.update_url(); } } @@ -212,12 +217,12 @@ pub fn dot_command(context: &mut Context, line: &str) -> bool { } else if val_lower == "off" || val_lower == "false" || val_lower == "0" { context.args.no_completion = true; } else { - out_err!(context, "Invalid value for .completion: '{}'. Use 'on' or 'off'.", value); + out_err!(context, "Error: invalid value for .completion: '{}'. Use 'on' or 'off'.", value); } } } _ => { - out_err!(context, "Unknown client setting '.{}'. Available: .format, .completion", key); + out_err!(context, "Error: unknown client setting '.{}'. Available: .format, .completion", key); } } true @@ -239,11 +244,6 @@ pub fn set_args(context: &mut Context, query: &str) -> Result = vec![]; buf.push(format!("{key}={value}")); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a64390e..2a2e42d 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -240,6 +240,27 @@ fn complete_file_paths(partial: &str) -> Vec dirs_list } +/// If `set_query` looks like `set = ` and `` is a known +/// client-side dot-command setting, return a suggestion hint string. +/// Called after server-side validation fails so the user is nudged toward +/// the correct syntax. +fn dot_command_hint(set_query: &str) -> Option { + use once_cell::sync::Lazy; + use regex::Regex; + static SET_KEY_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?i)^set\s+(\w+)\s*=").unwrap() + }); + let q = set_query.trim().trim_end_matches(';').trim(); + let caps = SET_KEY_RE.captures(q)?; + let key = caps.get(1)?.as_str().to_lowercase(); + match key.as_str() { + "completion" => Some( + "Use '.completion = on|off' to control client-side tab completion".to_string(), + ), + _ => None, + } +} + /// Expand a command alias to a full SQL query, returning `None` if the command /// is not a known alias. /// @@ -711,7 +732,19 @@ impl TuiApp { if trimmed.starts_with('.') { self.history.add(trimmed.clone()); self.output.push_line(trimmed.clone()); + // Attach a temporary channel so dot_command output goes to the + // output pane instead of eprintln! (which would corrupt the TUI). + let (tx, mut rx) = mpsc::unbounded_channel::(); + self.context.tui_output_tx = Some(tx); dot_command(&mut self.context, &trimmed); + self.context.tui_output_tx = None; + while let Ok(TuiMsg::Line(line)) = rx.try_recv() { + if line.starts_with("Error: ") { + self.output.push_error(&line); + } else { + self.output.push_ansi_text(&line); + } + } self.completer.set_enabled(!self.context.args.no_completion); self.reset_textarea(); return false; @@ -1092,6 +1125,102 @@ impl TuiApp { } } + // ── Dot-command completion ───────────────────────────────────────── + { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + let (cursor_row, cursor_col) = self.textarea.cursor(); + let first_line: String = self.textarea.lines().first().cloned().unwrap_or_default(); + + if cursor_row == 0 && first_line.trim_start().starts_with('.') { + let line_to_cursor = &first_line[..cursor_col.min(first_line.len())]; + + if let Some(eq_pos) = line_to_cursor.find('=') { + // Case B: after '=' → complete value + let key_part = line_to_cursor[..eq_pos].trim().trim_start_matches('.'); + let after_eq = &line_to_cursor[eq_pos + 1..]; + let leading_spaces = after_eq.len() - after_eq.trim_start().len(); + let value_col = eq_pos + 1 + leading_spaces; + let partial = &line_to_cursor[value_col..]; + + let candidates: &[(&str, &str)] = match key_part { + "format" => &[ + ("client:auto", "auto"), + ("client:vertical", "vertical"), + ("client:horizontal", "horizontal"), + ], + "completion" => &[ + ("on", "enable"), + ("off", "disable"), + ], + _ => &[], + }; + + let items: Vec = candidates.iter() + .filter(|(v, _)| v.to_lowercase().starts_with(&partial.to_lowercase())) + .map(|(v, d)| CompletionItem { + value: v.to_string(), + description: d.to_string(), + item_type: ItemType::Table, + }) + .collect(); + + if !items.is_empty() { + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > partial.len() || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, value_col as u16)); + self.textarea.delete_str(partial.len()); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new(items, value_col, value_col, 0, false); + self.completion_state = Some(cs); + } + } + return; + } + + // Case A: no '=' yet → complete command name + let items: Vec = [ + (".format = ", "output format"), + (".completion = ", "tab completion"), + ] + .iter() + .filter(|(c, _)| c.starts_with(line_to_cursor)) + .map(|(c, d)| CompletionItem { + value: c.to_string(), + description: d.to_string(), + item_type: ItemType::Table, + }) + .collect(); + + if !items.is_empty() { + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > cursor_col || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, 0)); + self.textarea.delete_str(cursor_col); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new(items, 0, 0, 0, false); + self.completion_state = Some(cs); + } + } + return; + } + } + // Compute current cursor position in terms of the full content let (cursor_row, cursor_col) = self.textarea.cursor(); let lines = self.textarea.lines(); @@ -1855,22 +1984,6 @@ impl TuiApp { // that change server-side parameters (i.e. modify args.extra), first validate // them by sending SELECT 1 with the new settings; bail out on rejection. for q in &queries { - // Intercept `set completion = ...` before it reaches the server path, - // since completion is a client-only setting and set_args would otherwise - // print a help message to stderr (bypassing the TUI output pane). - { - use once_cell::sync::Lazy; - use regex::Regex; - static COMPLETION_SET_RE: Lazy = Lazy::new(|| - Regex::new(r"(?i)^\s*set\s+completion\s*=").unwrap() - ); - if COMPLETION_SET_RE.is_match(q) { - self.push_sql_echo(q.trim()); - self.output.push_error("'set completion' is a client setting — use '.completion = on|off'"); - return; - } - } - let extra_before = self.context.args.extra.clone(); let mut test_ctx = self.context.clone(); if set_args(&mut test_ctx, q).unwrap_or(false) { @@ -1879,6 +1992,9 @@ impl TuiApp { if let Err(e) = validate_setting(&mut test_ctx).await { self.push_sql_echo(q.trim()); self.output.push_error(&format!("Error: {e}")); + if let Some(hint) = dot_command_hint(q) { + self.output.push_error(&format!("Tip: {hint}")); + } return; } } From 3324b8ba58d59c83647695e308c810c19ab67cc1 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:14:13 +0100 Subject: [PATCH 105/147] Apply usage bonus to column candidates in completion ranking Columns were the only candidate type (tables, schemas, functions all had it) not receiving a usage frequency bonus. Frequently-used column names now rank higher within their priority class, matching the behaviour of the other types. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/candidates.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/completion/candidates.rs b/src/completion/candidates.rs index a73f1ff..6c0640c 100644 --- a/src/completion/candidates.rs +++ b/src/completion/candidates.rs @@ -245,6 +245,9 @@ pub fn collect_candidates( PRIORITY_COLUMN_OTHER }; + let usage_count = usage_tracker.get_count(ItemType::Column, &column); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + items.push(Candidate { display: display.clone(), insert: display, @@ -253,7 +256,7 @@ pub fn collect_candidates( schema: schema.clone(), table_name: Some(table), alts, - priority: base, + priority: base.saturating_add(usage_bonus), }); } From 368b7a5f94193240078e38db0f20f4a66085e360 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:18:28 +0100 Subject: [PATCH 106/147] Fix column usage tracking for non-standard clause order and dot-mode lookup Two bugs prevented column usage bonuses from being applied: 1. extract_column_names required FROM to appear after SELECT, so queries written as 'FROM table SELECT col' (Firebolt allows this) never had their columns tracked. Now extracts the SELECT list by scanning forward from SELECT to the next major clause (FROM/WHERE/GROUP/ORDER/etc.), regardless of what came before SELECT. 2. PriorityScorer::score passed the fully-qualified column name (e.g. 'engine_user_query_history.query_text') to the usage tracker, but the tracker stores bare names ('query_text'). The lookup always returned 0. Now strips the table prefix before the get_count call for columns. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/priority_scorer.rs | 9 +++++++-- src/completion/usage_tracker.rs | 31 +++++++++++++++++-------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/completion/priority_scorer.rs b/src/completion/priority_scorer.rs index 2d74965..6a2fefe 100644 --- a/src/completion/priority_scorer.rs +++ b/src/completion/priority_scorer.rs @@ -59,8 +59,13 @@ impl PriorityScorer { tables_in_statement: &[String], column_table: Option<&str>, ) -> u32 { - // Get usage count - let usage_count = self.usage_tracker.get_count(item_type, name); + // For columns the tracker stores bare names; strip any "table." prefix. + let lookup_name: &str = if matches!(item_type, ItemType::Column) { + name.rsplit('.').next().unwrap_or(name) + } else { + name + }; + let usage_count = self.usage_tracker.get_count(item_type, lookup_name); // Calculate base score from priority class let base_score = self.calculate_priority_class( diff --git a/src/completion/usage_tracker.rs b/src/completion/usage_tracker.rs index 1ea28f5..b915902 100644 --- a/src/completion/usage_tracker.rs +++ b/src/completion/usage_tracker.rs @@ -135,15 +135,17 @@ impl UsageTracker { let mut columns = Vec::new(); let query_upper = query.to_uppercase(); - // Pattern: SELECT columns FROM + // Pattern: SELECT columns (up to the next major clause or end of string) + // Works regardless of whether FROM appears before or after SELECT. if let Some(select_pos) = query_upper.find("SELECT") { - if let Some(from_pos) = query_upper.find("FROM") { - // Only extract if FROM comes after SELECT - if from_pos > select_pos + 6 { - let between = &query[select_pos + 6..from_pos]; - columns.extend(Self::extract_column_list(between)); - } - } + let rest_upper = &query_upper[select_pos + 6..]; + let end = ["FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "UNION"] + .iter() + .filter_map(|kw| rest_upper.find(kw)) + .min() + .unwrap_or(rest_upper.len()); + let between = &query[select_pos + 6..select_pos + 6 + end]; + columns.extend(Self::extract_column_list(between)); } // Pattern: WHERE column = value @@ -295,19 +297,20 @@ mod tests { #[test] fn test_extract_column_names_with_keywords_out_of_order() { - // Regression test for panic when FROM appears before SELECT + // FROM before SELECT — should still extract columns after SELECT let columns = UsageTracker::extract_column_names("from test select val"); - // Should not panic, and should not extract columns since FROM comes before SELECT - assert_eq!(columns.len(), 0); + assert!(columns.contains(&"val".to_string())); } #[test] fn test_extract_column_names_edge_cases() { - // Test with only SELECT, no FROM + // SELECT with no following clause — column should still be extracted let columns = UsageTracker::extract_column_names("SELECT user_id"); - assert_eq!(columns.len(), 0); // No FROM, so no columns extracted + assert!(columns.contains(&"user_id".to_string())); - // Test with FROM immediately after SELECT (no space for columns) + // "SELECTFROM" is not a valid SELECT keyword (no space), so find("SELECT") + // still matches at position 0; rest = "FROM users", end = 0 (FROM at pos 0), + // between = "" → no columns extracted. let columns = UsageTracker::extract_column_names("SELECTFROM users"); assert_eq!(columns.len(), 0); } From 21e50bab734673026e6d4b62a88e0c3bc8e1b806 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:28:11 +0100 Subject: [PATCH 107/147] Remove dead code: unused functions, fields, and allow(dead_code) suppressions - Remove apply_output_limits() from query.rs (inline limits still used) - Remove is_auto_display() and should_use_colors() from args.rs - Remove get_args() #[allow(dead_code)] annotation - Remove item_type field from FuzzyItem in fuzzy_completer.rs - Remove push_prompt() from output_pane.rs; remove allow on push_prompt_highlighted - Remove detect_context, CompletionContext, KEYWORD_PATTERN, find_last_keyword, is_inside_string_or_comment from context_detector.rs (replaced by context_analyzer) - Remove get_completions() from schema_cache.rs (unused after context_detector cleanup) All 173 unit tests and 32 integration tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/args.rs | 25 --- src/completion/context_detector.rs | 238 ----------------------------- src/completion/fuzzy_completer.rs | 6 +- src/completion/schema_cache.rs | 84 ---------- src/query.rs | 32 ---- src/tui/output_pane.rs | 19 --- 6 files changed, 1 insertion(+), 403 deletions(-) diff --git a/src/args.rs b/src/args.rs index 5cbc483..a603552 100644 --- a/src/args.rs +++ b/src/args.rs @@ -158,27 +158,7 @@ impl Args { self.get_display_mode().eq_ignore_ascii_case("horizontal") } - #[allow(dead_code)] - pub fn is_auto_display(&self) -> bool { - self.get_display_mode().eq_ignore_ascii_case("auto") - } - - #[allow(dead_code)] - /// Determine if colors should be used for syntax highlighting - pub fn should_use_colors(&self) -> bool { - // Check NO_COLOR environment variable (standard: no-color.org) - if std::env::var("NO_COLOR").is_ok() { - return false; - } - // Check command-line flag - if self.no_color { - return false; - } - - // Default: use colors - true - } } pub fn normalize_extras(extras: Vec, encode: bool) -> Result, Box> { @@ -212,7 +192,6 @@ pub fn normalize_extras(extras: Vec, encode: bool) -> Result } // Apply defaults and possibly update them. -#[allow(dead_code)] pub fn get_args() -> Result> { let config_path = config_path()?; @@ -484,22 +463,18 @@ mod tests { let mut args = Args::parse_args_default_or_exit(); args.format = String::from("client:auto"); - assert!(args.is_auto_display()); assert!(!args.is_vertical_display()); assert!(!args.is_horizontal_display()); args.format = String::from("client:vertical"); - assert!(!args.is_auto_display()); assert!(args.is_vertical_display()); assert!(!args.is_horizontal_display()); args.format = String::from("client:horizontal"); - assert!(!args.is_auto_display()); assert!(!args.is_vertical_display()); assert!(args.is_horizontal_display()); args.format = String::from("PSQL"); - assert!(!args.is_auto_display()); assert!(!args.is_vertical_display()); assert!(!args.is_horizontal_display()); } diff --git a/src/completion/context_detector.rs b/src/completion/context_detector.rs index cbe3b92..43e9a49 100644 --- a/src/completion/context_detector.rs +++ b/src/completion/context_detector.rs @@ -1,97 +1,3 @@ -use once_cell::sync::Lazy; -use regex::Regex; - -#[allow(dead_code)] -static KEYWORD_PATTERN: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|FULL|OUTER|ON|GROUP|ORDER|BY|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|DROP|ALTER|TABLE|DATABASE|INDEX|VIEW|AS|DISTINCT|ALL|AND|OR|NOT|IN|EXISTS|BETWEEN|LIKE|IS|NULL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE)\b").unwrap() -}); - -#[derive(Debug, PartialEq, Clone)] -#[allow(dead_code)] -pub enum CompletionContext { - Keyword, // Start of statement or after whitespace - TableName, // After FROM, JOIN, INTO, UPDATE - ColumnName, // After SELECT, WHERE, ON, GROUP BY, ORDER BY - FunctionName, // At start or after operators - SchemaQualified, // After "schema_name." - Nothing, // Inside string/comment, no completion -} - -/// Detects what type of completion is appropriate at the given position -#[allow(dead_code)] -pub fn detect_context(line: &str, pos: usize) -> CompletionContext { - // Clamp position to line length - let pos = pos.min(line.len()); - - // Don't complete inside strings or comments - if is_inside_string_or_comment(line, pos) { - return CompletionContext::Nothing; - } - - // Find the start of the current word - let word_start = find_word_start(line, pos); - let partial = &line[word_start..pos]; - - // If there's a dot in the partial word, it's schema-qualified - if partial.contains('.') { - return CompletionContext::SchemaQualified; - } - - // Get the context before the current word - let context_before = line[..word_start].trim().to_uppercase(); - - // If there's no context before and we're typing something, it's a keyword - if context_before.is_empty() { - return CompletionContext::Keyword; - } - - // Find the last keyword before our position - let last_keyword = find_last_keyword(&context_before); - - // Determine context based on last keyword - match last_keyword.as_deref() { - Some("FROM") | Some("JOIN") | Some("INTO") | Some("UPDATE") => { - CompletionContext::TableName - } - Some("SELECT") => { - // After SELECT, if we've seen other tokens (like *), we might be typing FROM - // Check if there are non-keyword tokens after SELECT - let _after_select = context_before - .split("SELECT") - .last() - .unwrap_or("") - .trim(); - - // If there's something after SELECT (like * or column names), the next word could be a keyword - // But we can't easily distinguish, so default to ColumnName (safer for usability) - CompletionContext::ColumnName - } - Some("WHERE") | Some("ON") | Some("HAVING") => { - CompletionContext::ColumnName - } - Some("GROUP") | Some("ORDER") => { - // Check if followed by BY - if context_before.ends_with("GROUP BY") || context_before.ends_with("ORDER BY") { - CompletionContext::ColumnName - } else { - CompletionContext::Keyword - } - } - Some("BY") => { - // Check if this is part of GROUP BY or ORDER BY - if context_before.contains("GROUP BY") || context_before.contains("ORDER BY") { - CompletionContext::ColumnName - } else { - CompletionContext::Keyword - } - } - _ => { - // Default to keyword completion - CompletionContext::Keyword - } - } -} - /// Finds the start position of the word at the given position pub fn find_word_start(line: &str, pos: usize) -> usize { let bytes = line.as_bytes(); @@ -112,134 +18,10 @@ pub fn find_word_start(line: &str, pos: usize) -> usize { start } -/// Finds the last SQL keyword in the given text -fn find_last_keyword(text: &str) -> Option { - KEYWORD_PATTERN - .find_iter(text) - .last() - .map(|m| m.as_str().to_uppercase()) -} - -/// Checks if the position is inside a string literal or comment -fn is_inside_string_or_comment(line: &str, pos: usize) -> bool { - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut escape_next = false; - - for (i, ch) in line.char_indices() { - if i >= pos { - break; - } - - if escape_next { - escape_next = false; - continue; - } - - match ch { - '\\' if in_single_quote || in_double_quote => { - escape_next = true; - } - '\'' if !in_double_quote => { - in_single_quote = !in_single_quote; - } - '"' if !in_single_quote => { - in_double_quote = !in_double_quote; - } - '-' if !in_single_quote && !in_double_quote => { - // Check for line comment - if i + 1 < line.len() && line.as_bytes()[i + 1] == b'-' { - return true; // Rest of line is a comment - } - } - _ => {} - } - } - - in_single_quote || in_double_quote -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_detect_table_context() { - assert_eq!( - detect_context("SELECT * FROM us", 17), - CompletionContext::TableName - ); - assert_eq!( - detect_context("SELECT * FROM users JOIN or", 28), - CompletionContext::TableName - ); - assert_eq!( - detect_context("INSERT INTO us", 14), - CompletionContext::TableName - ); - assert_eq!( - detect_context("UPDATE us", 9), - CompletionContext::TableName - ); - } - - #[test] - fn test_detect_column_context() { - assert_eq!( - detect_context("SELECT col", 10), - CompletionContext::ColumnName - ); - assert_eq!( - detect_context("SELECT * FROM users WHERE col", 29), - CompletionContext::ColumnName - ); - assert_eq!( - detect_context("SELECT * FROM users GROUP BY col", 32), - CompletionContext::ColumnName - ); - assert_eq!( - detect_context("SELECT * FROM users ORDER BY col", 32), - CompletionContext::ColumnName - ); - } - - #[test] - fn test_detect_keyword_context() { - assert_eq!(detect_context("SEL", 3), CompletionContext::Keyword); - // After "SELECT *", the completion context is ambiguous - could be column or keyword - // Our implementation defaults to ColumnName for better usability after SELECT - // (columns are more common than keywords after SELECT) - // If user types "SELECT * F" they'll see both FROM keyword and any matching columns - } - - #[test] - fn test_detect_schema_qualified() { - assert_eq!( - detect_context("SELECT * FROM public.us", 23), - CompletionContext::SchemaQualified - ); - } - - #[test] - fn test_ignore_strings() { - assert_eq!( - detect_context("SELECT 'FROM us", 15), - CompletionContext::Nothing - ); - assert_eq!( - detect_context("SELECT \"col", 11), - CompletionContext::Nothing - ); - } - - #[test] - fn test_ignore_comments() { - assert_eq!( - detect_context("-- SELECT FROM us", 17), - CompletionContext::Nothing - ); - } - #[test] fn test_find_word_start() { assert_eq!(find_word_start("SELECT * FROM users", 19), 14); @@ -247,24 +29,4 @@ mod tests { assert_eq!(find_word_start("public.users", 12), 0); assert_eq!(find_word_start("SELECT * FROM public.us", 23), 14); } - - #[test] - fn test_find_last_keyword() { - assert_eq!( - find_last_keyword("SELECT * FROM"), - Some("FROM".to_string()) - ); - assert_eq!( - find_last_keyword("SELECT * FROM users WHERE"), - Some("WHERE".to_string()) - ); - assert_eq!(find_last_keyword("no keywords here"), None); - } - - #[test] - fn test_is_inside_string() { - assert!(is_inside_string_or_comment("'hello world", 8)); - assert!(!is_inside_string_or_comment("'hello' world", 10)); - assert!(is_inside_string_or_comment("\"hello world", 8)); - } } diff --git a/src/completion/fuzzy_completer.rs b/src/completion/fuzzy_completer.rs index 8c77820..881aaa6 100644 --- a/src/completion/fuzzy_completer.rs +++ b/src/completion/fuzzy_completer.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use super::candidates::{self, collect_candidates}; use super::schema_cache::SchemaCache; -use super::usage_tracker::{ItemType, UsageTracker}; +use super::usage_tracker::UsageTracker; pub struct FuzzyCompleter { cache: Arc, @@ -78,7 +78,6 @@ fn candidate_to_fuzzy(c: candidates::Candidate) -> FuzzyItem { FuzzyItem { label: c.display, description: c.description.to_string(), - item_type: c.item_type, insert_value: c.insert, priority: c.priority, } @@ -91,9 +90,6 @@ pub struct FuzzyItem { pub label: String, /// Short description matching tab-completion labels: "table" / "column" / "function" / "schema". pub description: String, - /// The logical type. - #[allow(dead_code)] - pub item_type: ItemType, /// Text to insert into the textarea when accepted. pub insert_value: String, /// Priority tier used as a tiebreaker when fuzzy scores are equal. diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index f365234..d621030 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -315,90 +315,6 @@ impl SchemaCache { .unwrap_or_default() } - /// Synchronous method to get completions from cache - #[allow(dead_code)] - pub fn get_completions( - &self, - context: super::context_detector::CompletionContext, - prefix: &str, - ) -> Vec { - use super::context_detector::CompletionContext; - - let prefix_lower = prefix.to_lowercase(); - - match context { - CompletionContext::Keyword => { - // Return keywords - self.keywords - .iter() - .filter(|k| k.to_lowercase().starts_with(&prefix_lower)) - .cloned() - .collect() - } - CompletionContext::TableName => { - // Return table names - let tables = self.tables.read().unwrap(); - tables - .values() - .map(|t| { - if t.schema_name == "public" || t.schema_name.is_empty() { - t.table_name.clone() - } else { - format!("{}.{}", t.schema_name, t.table_name) - } - }) - .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) - .collect() - } - CompletionContext::ColumnName => { - // Return column names from all tables - let tables = self.tables.read().unwrap(); - let mut columns = HashSet::new(); - for table in tables.values() { - for column in &table.columns { - columns.insert(column.name.clone()); - } - } - columns - .into_iter() - .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) - .collect() - } - CompletionContext::FunctionName => { - // Return function names - let functions = self.functions.read().unwrap(); - functions - .iter() - .filter(|f| f.to_lowercase().starts_with(&prefix_lower)) - .cloned() - .collect() - } - CompletionContext::SchemaQualified => { - // Handle schema.table completion - if let Some(dot_pos) = prefix.rfind('.') { - let schema = &prefix[..dot_pos]; - let table_prefix = &prefix[dot_pos + 1..]; - let table_prefix_lower = table_prefix.to_lowercase(); - - let tables = self.tables.read().unwrap(); - tables - .values() - .filter(|t| t.schema_name == schema) - .map(|t| format!("{}.{}", t.schema_name, t.table_name)) - .filter(|name| { - name.split('.').nth(1).unwrap_or("") - .to_lowercase() - .starts_with(&table_prefix_lower) - }) - .collect() - } else { - Vec::new() - } - } - CompletionContext::Nothing => Vec::new(), - } - } - /// Async method to refresh schema from database pub async fn refresh(&self, context: &mut Context) -> Result<(), Box> { // Check if already refreshing diff --git a/src/query.rs b/src/query.rs index f19ee3b..468be21 100644 --- a/src/query.rs +++ b/src/query.rs @@ -129,38 +129,6 @@ fn format_number(n: u64) -> String { const INTERACTIVE_MAX_ROWS: usize = 10_000; const INTERACTIVE_MAX_BYTES: usize = 1_048_576; // 1 MB -// Limit rows for interactive display, returning a slice and an optional truncation message. -// Bytes are estimated as the sum of JSON string lengths across all cells in a row. -#[allow(dead_code)] -fn apply_output_limits(rows: &[Vec]) -> (&[Vec], Option) { - let mut byte_count = 0usize; - for (i, row) in rows.iter().enumerate() { - if i >= INTERACTIVE_MAX_ROWS { - return ( - &rows[..i], - Some(format!( - "Showing {} of {} rows (use /view to see all).", - format_number(i as u64), - format_number(rows.len() as u64), - )), - ); - } - for val in row { - byte_count += val.to_string().len(); - } - if byte_count > INTERACTIVE_MAX_BYTES { - return ( - &rows[..=i], - Some(format!( - "Showing {} of {} rows (use /view to see all).", - format_number((i + 1) as u64), - format_number(rows.len() as u64), - )), - ); - } - } - (rows, None) -} /// Handle client-side dot commands: `.format = value`, `.completion = on|off`, etc. /// Returns `true` if the input was a dot command (even if the key was unknown), diff --git a/src/tui/output_pane.rs b/src/tui/output_pane.rs index d632f1e..722bf0c 100644 --- a/src/tui/output_pane.rs +++ b/src/tui/output_pane.rs @@ -52,25 +52,6 @@ impl OutputPane { self.scroll_to_bottom(); } - #[allow(dead_code)] - /// Push the echoed prompt line (`❯ SELECT …`). - /// The `❯ ` prefix is rendered in green+bold; the SQL text in yellow. - /// Continuation lines (indented with spaces) are rendered fully in yellow. - pub fn push_prompt(&mut self, line: impl Into) { - let s: String = line.into(); - let ratatui_line = if let Some(sql) = s.strip_prefix("❯ ") { - Line::from(vec![ - Span::styled("❯ ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::styled(sql.to_string(), Style::default().fg(Color::Yellow)), - ]) - } else { - Line::from(Span::styled(s, Style::default().fg(Color::Yellow))) - }; - self.lines.push(OutputLine::from_line(ratatui_line)); - self.scroll_to_bottom(); - } - - #[allow(dead_code)] /// Push the echoed prompt line with syntax highlighting. /// /// `prefix` is `"❯ "` for the first line or `" "` for continuation lines. From 2f9e602f9595834aed91fdefdff3b59208cb802e Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:32:37 +0100 Subject: [PATCH 108/147] Highlight matching parenthesis when cursor is on a bracket When the block cursor rests on '(', ')', '[', or ']', the matching bracket is highlighted in bold LightCyan so both ends of the pair are immediately visible. Implementation: - find_matching_paren(): scans forward (from '('/']') or backward (from ')'/']') tracking nesting depth; works across newlines in multiline queries - apply_textarea_highlights(): computes the cursor's byte offset, calls find_matching_paren, and applies the paren style on top of syntax highlighting for the matched character - Cursor character itself keeps its existing reversed block-cursor style; only the non-cursor end receives the cyan highlight Six unit tests cover forward, backward, nested, square bracket, multiline, and no-match cases. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 5 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2a2e42d..e329244 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -295,6 +295,58 @@ fn url_decode_setting(s: &str) -> String { } } +/// If the byte at `cursor_byte` in `text` is a bracket (`(`, `)`, `[`, `]`), +/// scan forward or backward (counting nesting) and return the byte offset of +/// the matching bracket. Returns `None` if the cursor is not on a bracket or +/// no match exists in the text. +/// +/// The search deliberately ignores string/comment context for simplicity; +/// occasional false highlights inside string literals are acceptable. +fn find_matching_paren(text: &str, cursor_byte: usize) -> Option { + let bytes = text.as_bytes(); + if cursor_byte >= bytes.len() { + return None; + } + // `same` = character at cursor (e.g. `(`). + // `other` = the bracket we are searching for (e.g. `)`). + // `forward` = search direction. + // + // In both directions: encountering `same` means more nesting (depth++), + // encountering `other` means less nesting (depth--); match when depth==0. + let (same, other, forward) = match bytes[cursor_byte] { + b'(' => (b'(', b')', true), + b')' => (b')', b'(', false), + b'[' => (b'[', b']', true), + b']' => (b']', b'[', false), + _ => return None, + }; + let mut depth = 1i32; + if forward { + for i in (cursor_byte + 1)..bytes.len() { + if bytes[i] == same { + depth += 1; + } else if bytes[i] == other { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + } + } else { + for i in (0..cursor_byte).rev() { + if bytes[i] == same { + depth += 1; + } else if bytes[i] == other { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + } + } + None +} + fn format_with_commas(n: u64) -> String { let s = n.to_string(); let mut result = String::with_capacity(s.len() + s.len() / 3); @@ -2230,14 +2282,43 @@ impl TuiApp { buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, ) { + use ratatui::style::Modifier; + let lines = self.textarea.lines(); let full_text = lines.join("\n"); let spans = self.highlighter.highlight_to_spans(&full_text); - if spans.is_empty() { + + let (cursor_row, cursor_col) = self.textarea.cursor(); + + // Compute the byte offset of the cursor character in `full_text`. + let cursor_byte = { + let mut off = 0usize; + for (i, line) in lines.iter().enumerate() { + if i == cursor_row { + off += line + .char_indices() + .nth(cursor_col) + .map(|(b, _)| b) + .unwrap_or(line.len()); + break; + } + off += line.len() + 1; // +1 for '\n' + } + off + }; + + // Find matching bracket for the character under the cursor, if any. + let paren_match_byte = find_matching_paren(&full_text, cursor_byte); + + // Highlight style applied to the matching bracket. + let paren_style = Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD); + + if spans.is_empty() && paren_match_byte.is_none() { return; } - let (cursor_row, cursor_col) = self.textarea.cursor(); let row_scroll = self.ta_row_top as usize; let col_scroll = self.ta_col_top as usize; @@ -2278,17 +2359,27 @@ impl TuiApp { continue; } - // Find the highest-priority span covering this byte position. + let screen_pos = ratatui::layout::Position::new(screen_x, screen_y); + + // Apply syntax highlighting. if let Some((_, style)) = spans.iter().find(|(r, _)| r.start <= byte_pos && byte_pos < r.end) { - let pos = ratatui::layout::Position::new(screen_x, screen_y); - if let Some(cell) = buf.cell_mut(pos) { + if let Some(cell) = buf.cell_mut(screen_pos) { // Patch only the foreground colour; preserve bg, modifiers etc. let current = cell.style(); cell.set_style(current.patch(*style)); } } + + // Highlight the matching bracket (applied on top of syntax colour). + if Some(byte_pos) == paren_match_byte { + if let Some(cell) = buf.cell_mut(screen_pos) { + let current = cell.style(); + cell.set_style(current.patch(paren_style)); + } + } + char_col += 1; } @@ -2766,4 +2857,52 @@ mod tests { // Nested quotes: only outer layer stripped assert_eq!(strip_outer_quotes("\"'inner'\""), "'inner'"); } + + #[test] + fn test_find_matching_paren_forward() { + // cursor on '(' at position 3 — match is ')' at position 9 + let s = "foo(bar)baz"; + assert_eq!(find_matching_paren(s, 3), Some(7)); + } + + #[test] + fn test_find_matching_paren_backward() { + // cursor on ')' at position 7 — match is '(' at position 3 + let s = "foo(bar)baz"; + assert_eq!(find_matching_paren(s, 7), Some(3)); + } + + #[test] + fn test_find_matching_paren_nested() { + // "foo(bar(baz)qux)" — cursor on outer '(' at 3, match ')' at 15 + let s = "foo(bar(baz)qux)"; + assert_eq!(find_matching_paren(s, 3), Some(15)); + // cursor on inner '(' at 7, match ')' at 11 + assert_eq!(find_matching_paren(s, 7), Some(11)); + } + + #[test] + fn test_find_matching_paren_no_match() { + // Unmatched '(' — no closing paren + assert_eq!(find_matching_paren("foo(bar", 3), None); + // Cursor not on a bracket + assert_eq!(find_matching_paren("foo(bar)baz", 0), None); + // Cursor beyond end + assert_eq!(find_matching_paren("foo", 10), None); + } + + #[test] + fn test_find_matching_paren_square_brackets() { + let s = "arr[1]"; + assert_eq!(find_matching_paren(s, 3), Some(5)); + assert_eq!(find_matching_paren(s, 5), Some(3)); + } + + #[test] + fn test_find_matching_paren_multiline() { + // Newline between parens (joined text from textarea lines) + let s = "SELECT (\n 1 + 2\n)"; + // '(' is at byte 7, ')' is at the last byte + assert_eq!(find_matching_paren(s, 7), Some(s.len() - 1)); + } } From f22b4bc8396314989446097118448503b4ae5010 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:34:03 +0100 Subject: [PATCH 109/147] Add background highlight to matching bracket (same as current-line bg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matching bracket now gets Color::Indexed(234) as background — the same dark-gray used for the current-line highlight — so the highlight is consistent with the existing visual language of the TUI. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e329244..df96caf 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2313,6 +2313,7 @@ impl TuiApp { // Highlight style applied to the matching bracket. let paren_style = Style::default() .fg(Color::LightCyan) + .bg(Color::Indexed(234)) // same as current-line background .add_modifier(Modifier::BOLD); if spans.is_empty() && paren_match_byte.is_none() { From 499b672ca115fcbd92fe2061012193c49d74c9ee Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:37:13 +0100 Subject: [PATCH 110/147] Alt+F: preserve slash-command prefix when formatting SQL For /benchmark and /watch, only the SQL argument is passed to sqlformat; the command prefix (including any optional numeric count) is kept verbatim. For /run the argument is a file path so formatting is skipped entirely. Plain SQL (no slash prefix) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index df96caf..936ca84 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2093,18 +2093,39 @@ impl TuiApp { // ── Textarea helpers ───────────────────────────────────────────────────── /// Format the current textarea content as SQL (Alt+F). + /// + /// For slash commands with a SQL argument (`/benchmark`, `/watch`), only + /// the SQL part after the command prefix is formatted. `/run` takes a + /// file path, not SQL, so nothing is formatted in that case. fn format_sql(&mut self) { - let sql = self.textarea.lines().join("\n"); - if sql.trim().is_empty() { + let full = self.textarea.lines().join("\n"); + if full.trim().is_empty() { + return; + } + + let first_line = self.textarea.lines().first().map(|s| s.as_str()).unwrap_or(""); + + // /run takes a file path — nothing to format. + if first_line.starts_with("/run ") { return; } + + // For /benchmark and /watch, preserve the command prefix (including + // any optional numeric argument) and format only the SQL portion. + let (prefix, sql) = if let Some(arg_byte) = find_slash_arg_col(first_line) { + (&full[..arg_byte], &full[arg_byte..]) + } else { + ("", full.as_str()) + }; + let options = sqlformat::FormatOptions { indent: sqlformat::Indent::Spaces(2), uppercase: Some(true), ..sqlformat::FormatOptions::default() }; - let formatted = sqlformat::format(&sql, &sqlformat::QueryParams::None, &options); - if formatted != sql { + let formatted_sql = sqlformat::format(sql, &sqlformat::QueryParams::None, &options); + let formatted = format!("{}{}", prefix, formatted_sql); + if formatted != full { self.set_textarea_content(&formatted); } } From 2a3765b79854be4e33a2166038085dc0945c22e8 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:42:54 +0100 Subject: [PATCH 111/147] Alt+F: handle SQL on next line and include /run in formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously Alt+F broke on /benchmarkSELECT because find_slash_arg_col requires the command and SQL to be on the same line. Adds slash_cmd_sql_offset() which handles both layouts: /benchmark 5 SELECT … ← SQL same line (existing) /benchmark 5SELECT … ← SQL on next line (new) /benchmarkSELECT … ← bare command, SQL on next line (new) /run is now treated the same as /benchmark and /watch (its SQL argument is formatted) rather than being skipped entirely. Five unit tests cover same-line, same-line-with-count, next-line, next-line-with-count, and no-match cases. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 135 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 121 insertions(+), 14 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 936ca84..c9c8809 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -123,6 +123,9 @@ fn read_file_content(path: &str) -> Result { /// For `/benchmark` and `/watch`, an optional leading integer (the run count / /// interval) is skipped; the return value points at the first character of the /// actual query or `@` token. +/// +/// Only matches when SQL is on the same line as the command (space-separated). +/// Use `slash_cmd_sql_offset` when SQL may be on the next line. fn find_slash_arg_col(first_line: &str) -> Option { if first_line.starts_with("/run ") { return Some("/run ".len()); @@ -143,6 +146,64 @@ fn find_slash_arg_col(first_line: &str) -> Option { None } +/// Return the byte offset within `full` (textarea lines joined with `\n`) +/// where the SQL argument of a slash command starts. +/// +/// Handles both layouts: +/// - SQL on the same line: `/benchmark 5 SELECT …` → points at `S` +/// - SQL on the next line: `/benchmark 5\nSELECT …` → points at `S` +/// +/// For `/benchmark` and `/watch` an optional leading integer is consumed. +/// Returns `None` when `first_line` is not a recognised command or no SQL +/// follows the prefix. +fn slash_cmd_sql_offset(first_line: &str, has_next_line: bool) -> Option { + // (command, whether to skip an optional leading integer argument) + const CMDS: &[(&str, bool)] = &[ + ("/benchmark", true), + ("/watch", true), + ("/run", false), + ]; + for &(cmd, has_count) in CMDS { + if !first_line.starts_with(cmd) { + continue; + } + let after_cmd = &first_line[cmd.len()..]; + + if after_cmd.is_empty() { + // `/command` alone — SQL must be on the next line. + return if has_next_line { Some(first_line.len() + 1) } else { None }; + } + if !after_cmd.starts_with(' ') { + // Not this command (e.g. `/benchmarkfoo`). + continue; + } + + // Skip leading whitespace after the command name. + let stripped = after_cmd.trim_start(); + let spaces = after_cmd.len() - stripped.len(); + let mut offset = cmd.len() + spaces; + + // Optionally skip a leading integer count (`/benchmark N`, `/watch N`). + if has_count { + let tok = stripped.split_whitespace().next().unwrap_or(""); + if tok.parse::().is_ok() { + let after_num = &stripped[tok.len()..]; + let more_spaces = after_num.len() - after_num.trim_start().len(); + offset += tok.len() + more_spaces; + } + } + + return if offset >= first_line.len() { + // The whole first line was consumed by the prefix; SQL is on the + // next line (or there is none). + if has_next_line { Some(first_line.len() + 1) } else { None } + } else { + Some(offset) + }; + } + None +} + /// Parse `/watch [N] ` arguments. /// Returns `(interval_secs, query_text)` where `interval_secs` defaults to 5. fn parse_watch_args(cmd: &str) -> (u64, String) { @@ -2094,9 +2155,10 @@ impl TuiApp { /// Format the current textarea content as SQL (Alt+F). /// - /// For slash commands with a SQL argument (`/benchmark`, `/watch`), only - /// the SQL part after the command prefix is formatted. `/run` takes a - /// file path, not SQL, so nothing is formatted in that case. + /// For `/benchmark`, `/watch`, and `/run` only the SQL argument is + /// formatted; the command prefix (including any optional numeric argument) + /// is preserved verbatim. SQL on the next line after the command is + /// handled as well as SQL on the same line. fn format_sql(&mut self) { let full = self.textarea.lines().join("\n"); if full.trim().is_empty() { @@ -2104,18 +2166,11 @@ impl TuiApp { } let first_line = self.textarea.lines().first().map(|s| s.as_str()).unwrap_or(""); + let has_next_line = self.textarea.lines().len() > 1; - // /run takes a file path — nothing to format. - if first_line.starts_with("/run ") { - return; - } - - // For /benchmark and /watch, preserve the command prefix (including - // any optional numeric argument) and format only the SQL portion. - let (prefix, sql) = if let Some(arg_byte) = find_slash_arg_col(first_line) { - (&full[..arg_byte], &full[arg_byte..]) - } else { - ("", full.as_str()) + let (prefix, sql) = match slash_cmd_sql_offset(first_line, has_next_line) { + Some(offset) => (&full[..offset], &full[offset..]), + None => ("", full.as_str()), }; let options = sqlformat::FormatOptions { @@ -2927,4 +2982,56 @@ mod tests { // '(' is at byte 7, ')' is at the last byte assert_eq!(find_matching_paren(s, 7), Some(s.len() - 1)); } + + // ── slash_cmd_sql_offset ───────────────────────────────────────────────── + + #[test] + fn test_slash_sql_offset_same_line() { + // SQL immediately after command + space + assert_eq!(slash_cmd_sql_offset("/benchmark SELECT 1", false), Some(11)); + assert_eq!(slash_cmd_sql_offset("/watch SELECT 1", false), Some(7)); + assert_eq!(slash_cmd_sql_offset("/run SELECT 1", false), Some(5)); + } + + #[test] + fn test_slash_sql_offset_same_line_with_count() { + // /benchmark 5 SELECT — count is skipped + let full = "/benchmark 5 SELECT 1"; + assert_eq!(slash_cmd_sql_offset("/benchmark 5 SELECT 1", false), Some(13)); + // Verify the split is correct + let offset = slash_cmd_sql_offset("/benchmark 5 SELECT 1", false).unwrap(); + assert_eq!(&full[..offset], "/benchmark 5 "); + assert_eq!(&full[offset..], "SELECT 1"); + } + + #[test] + fn test_slash_sql_offset_next_line_bare_command() { + // /benchmark\nSELECT — SQL on next line, no trailing space + let first = "/benchmark"; + let full = "/benchmark\nSELECT 1"; + let offset = slash_cmd_sql_offset(first, true).unwrap(); + assert_eq!(&full[..offset], "/benchmark\n"); + assert_eq!(&full[offset..], "SELECT 1"); + } + + #[test] + fn test_slash_sql_offset_next_line_with_count() { + // /benchmark 5\nSELECT — count on first line, SQL on second + let first = "/benchmark 5"; + let full = "/benchmark 5\nSELECT 1"; + let offset = slash_cmd_sql_offset(first, true).unwrap(); + assert_eq!(&full[..offset], "/benchmark 5\n"); + assert_eq!(&full[offset..], "SELECT 1"); + } + + #[test] + fn test_slash_sql_offset_no_match() { + // Plain SQL — not a slash command + assert_eq!(slash_cmd_sql_offset("SELECT 1", false), None); + // Bare command with no SQL and no next line + assert_eq!(slash_cmd_sql_offset("/benchmark", false), None); + assert_eq!(slash_cmd_sql_offset("/benchmark 5", false), None); + // Unknown command + assert_eq!(slash_cmd_sql_offset("/foo SELECT 1", false), None); + } } From 956b0d5e4ae718fd5b32f6e5feaea3b48fa5bcbe Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 17:52:21 +0100 Subject: [PATCH 112/147] Add Firebolt transaction support (protocol 2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transactions (BEGIN / COMMIT / ROLLBACK) now work correctly end-to-end: Protocol version Bump Firebolt-Protocol-Version header from 2.3 to 2.4, which is required by the server to enable transaction support. Response header handling (query.rs) - apply_update_parameters(): new helper that correctly parses the comma-separated "key=value,key=value" format of Firebolt-Update-Parameters and applies each pair via set_args. The previous code passed the whole string as a single set command, which silently corrupted multi-value headers. - remove_parameters(): new helper that parses the comma-separated key list in Firebolt-Remove-Parameters and removes each from args.extra. The previous code called unset_args with the full comma-joined string, which only worked for single-key responses. - Firebolt-Reset-Session: new handler — clears transaction_id and transaction_sequence_id from the session. The server sends this header after a successful COMMIT or ROLLBACK. Transaction state (context.rs) in_transaction() — returns true when transaction_id is present in args.extra (injected by the server via Firebolt-Update-Parameters after BEGIN, removed via Reset-Session / Remove-Parameters after COMMIT / ROLLBACK). TUI status bar (tui/mod.rs) Show a bold dark-orange " TXN " badge in the status bar whenever in_transaction() is true, so the user always knows they are inside an open transaction. Co-Authored-By: Claude Sonnet 4.6 --- src/context.rs | 10 ++++++++++ src/main.rs | 2 +- src/query.rs | 46 +++++++++++++++++++++++++++++++++++++++++----- src/tui/mod.rs | 18 +++++++++++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/context.rs b/src/context.rs index 157424c..996d53f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -135,6 +135,16 @@ impl Context { } } + /// Returns `true` when an explicit transaction is active on this session. + /// + /// Detected by the presence of a `transaction_id` URL parameter, which the + /// server injects via `Firebolt-Update-Parameters` after `BEGIN` and removes + /// via `Firebolt-Reset-Session` / `Firebolt-Remove-Parameters` after + /// `COMMIT` or `ROLLBACK`. + pub fn in_transaction(&self) -> bool { + self.args.extra.iter().any(|e| e.starts_with("transaction_id=")) + } + /// Returns `true` when running inside the TUI event loop. pub fn is_tui(&self) -> bool { self.tui_output_tx.is_some() diff --git a/src/main.rs b/src/main.rs index 90ad0f3..5f093f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ use tui::TuiApp; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); -pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.3"; +pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.4"; #[tokio::main] async fn main() { diff --git a/src/query.rs b/src/query.rs index 468be21..2edf97c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -252,6 +252,39 @@ pub fn unset_args(context: &mut Context, query: &str) -> Result Result<(), Box> { + for pair in header_value.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + set_args(context, &format!("set {}", pair))?; + } + Ok(()) +} + +/// Apply a `Firebolt-Remove-Parameters` or `Firebolt-Reset-Session` header. +/// +/// `keys` is a comma-separated list of parameter names to remove from +/// `context.args.extra`, e.g. `"transaction_id,transaction_sequence_id"`. +fn remove_parameters(context: &mut Context, keys: &str) { + for key in keys.split(',') { + let key = key.trim(); + if key.is_empty() { + continue; + } + let prefix = format!("{}=", key); + context.args.extra.retain(|e| !e.starts_with(&prefix)); + } + context.update_url(); +} + // Execute a query silently and return the response body (for internal use like schema queries) pub async fn query_silent(context: &mut Context, query_text: &str) -> Result> { let request = reqwest::Client::builder() @@ -463,15 +496,18 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< Ok(mut resp) => { let mut updated_url = false; for (header, value) in resp.headers() { - if header == "firebolt-remove-parameters" { - unset_args(context, format!("unset {}", value.to_str()?).as_str())?; + if header == "firebolt-update-parameters" { + apply_update_parameters(context, value.to_str()?)?; + updated_url = true; + } else if header == "firebolt-remove-parameters" { + remove_parameters(context, value.to_str()?); updated_url = true; - } else if header == "firebolt-update-parameters" { - set_args(context, format!("set {}", value.to_str()?).as_str())?; + } else if header == "firebolt-reset-session" { + // End of transaction: clear transaction_id and transaction_sequence_id. + remove_parameters(context, "transaction_id,transaction_sequence_id"); updated_url = true; } else if header == "X-REQUEST-ID" { maybe_request_id = value.to_str().map_or(None, |l| Some(String::from(l))); - updated_url = true; } else if header == "firebolt-update-endpoint" { let header_str = value.to_str()?; // Split the header at the '?' character diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c9c8809..411db76 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -21,7 +21,7 @@ use crossterm::{ use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Layout, Rect}, - style::{Color, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Terminal, @@ -2860,6 +2860,7 @@ impl TuiApp { }; let total = area.width as usize; + let in_txn = self.context.in_transaction(); let status = if let Some((msg, _)) = &self.flash_message { // Flash: error on the left in red, hint line on the right in normal style. @@ -2871,6 +2872,21 @@ impl TuiApp { Span::styled(right, Style::default().bg(Color::DarkGray).fg(Color::White)), ]; Paragraph::new(Line::from(spans)) + } else if in_txn { + // Transaction active: show a yellow "TXN" badge between conn info and hints. + let badge = " TXN "; + let pad = total.saturating_sub(conn_info.len() + badge.len() + right.len()); + let base = Style::default().bg(Color::DarkGray).fg(Color::White); + let txn_style = Style::default() + .bg(Color::Indexed(130)) // dark orange + .fg(Color::White) + .add_modifier(Modifier::BOLD); + let spans: Vec = vec![ + Span::styled(format!("{}{}", conn_info, " ".repeat(pad)), base), + Span::styled(badge, txn_style), + Span::styled(right, base), + ]; + Paragraph::new(Line::from(spans)) } else { let pad = total.saturating_sub(conn_info.len() + right.len()); let text = format!("{}{}{}", conn_info, " ".repeat(pad), right); From 7d286918c32de7c51d8d09a301dad3a6906abb4b Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 18:13:24 +0100 Subject: [PATCH 113/147] Fix TXN badge: propagate server param updates back to TuiApp context execute_queries clones context before spawning the query task, so transaction_id (set via Firebolt-Update-Parameters after BEGIN) was only applied to the clone and never reached self.context. Add TuiMsg::ParamUpdate(Vec) which carries the updated extras list. query.rs sends it whenever any of the three transaction-related response headers are processed; drain_query_output applies it to the live self.context so in_transaction() and the TXN badge work correctly. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 14 ++++++++++++-- src/tui/mod.rs | 4 ++++ src/tui_msg.rs | 5 +++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/query.rs b/src/query.rs index 2edf97c..23c358a 100644 --- a/src/query.rs +++ b/src/query.rs @@ -532,8 +532,18 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< updated_url = true; } } - if updated_url && context.args.verbose && !context.args.hide_pii { - out_err!(context, "URL: {}", context.url); + if updated_url { + if context.args.verbose && !context.args.hide_pii { + out_err!(context, "URL: {}", context.url); + } + // Propagate the new extras back to the TUI's own context + // (the task runs on a clone; without this the transaction + // badge and similar features would never see the changes). + if let Some(tx) = &context.tui_output_tx { + let _ = tx.send(crate::tui_msg::TuiMsg::ParamUpdate( + context.args.extra.clone(), + )); + } } let status = resp.status(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 411db76..fc5067e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -671,6 +671,10 @@ impl TuiApp { } self.context.last_result = Some(result); } + Ok(TuiMsg::ParamUpdate(extras)) => { + self.context.args.extra = extras; + self.context.update_url(); + } Ok(TuiMsg::StyledLines(lines)) => { self.output.push_tui_lines(lines); } diff --git a/src/tui_msg.rs b/src/tui_msg.rs index dd35217..98c2f8b 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -10,6 +10,11 @@ pub enum TuiMsg { ParsedResult(crate::table_renderer::ParsedResult), /// Overwrite the running-pane hint line (e.g. benchmark run progress). RunHint(String), + /// Propagate server-driven parameter changes back to the TUI's own context. + /// Sent whenever `firebolt-update-parameters`, `firebolt-remove-parameters`, + /// or `firebolt-reset-session` response headers are received so that the + /// transaction badge (and future features) reflect the updated state. + ParamUpdate(Vec), } /// A single rendered line made up of zero or more styled spans. From 8db0f56a153318ef2aa59edcb4db176564db03db Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 18:17:42 +0100 Subject: [PATCH 114/147] Add unit and integration tests for transaction support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests (query.rs) — no server required: - apply_update_parameters: single pair, multiple pairs, empty string, trailing comma, preserves pre-existing extras - remove_parameters: single key, multiple keys, exact prefix match (doesn't touch transaction_id_extra), preserves unrelated extras, empty-string no-op - in_transaction_lifecycle: full BEGIN→mid-sequence-bump→reset→ second-transaction state machine Integration tests (tests/cli.rs): - test_transaction_begin_commit_succeeds - test_transaction_begin_rollback_succeeds - test_transaction_id_appears_in_url_after_begin (--verbose) - test_transaction_id_absent_from_url_after_commit (--verbose) - test_transaction_id_absent_from_url_after_rollback (--verbose) - test_transaction_dml_commit (CREATE/BEGIN/INSERT/COMMIT/SELECT/DROP) - test_transaction_dml_rollback (CREATE/BEGIN/INSERT/ROLLBACK/SELECT/DROP) - test_transaction_sequential_transactions (two back-to-back transactions) Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 137 +++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) diff --git a/src/query.rs b/src/query.rs index 23c358a..601def5 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1407,4 +1407,141 @@ mod tests { assert_eq!(queries.len(), 1); assert_eq!(queries[0], r#"SELECT "hello";"#); } + + // ── Transaction parameter helpers ───────────────────────────────────────── + + fn make_context() -> Context { + let args = get_args().unwrap(); + Context::new(args) + } + + #[test] + fn test_apply_update_parameters_single_pair() { + let mut ctx = make_context(); + assert!(!ctx.in_transaction()); + apply_update_parameters(&mut ctx, "transaction_id=abc123").unwrap(); + assert!(ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_id=abc123")); + } + + #[test] + fn test_apply_update_parameters_multiple_pairs() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc123,transaction_sequence_id=1").unwrap(); + assert!(ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_id=abc123")); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_sequence_id=1")); + } + + #[test] + fn test_apply_update_parameters_empty_string_is_noop() { + let mut ctx = make_context(); + let before = ctx.args.extra.clone(); + apply_update_parameters(&mut ctx, "").unwrap(); + assert_eq!(ctx.args.extra, before); + assert!(!ctx.in_transaction()); + } + + #[test] + fn test_apply_update_parameters_trailing_comma_ignored() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=x,").unwrap(); + // trailing comma produces an empty token, which is skipped + assert!(ctx.in_transaction()); + assert_eq!(ctx.args.extra.iter().filter(|e| e.starts_with("transaction_id=")).count(), 1); + } + + #[test] + fn test_apply_update_parameters_preserves_existing_extras() { + let mut args = get_args().unwrap(); + args.extra = vec!["custom_param=hello".to_string()]; + let mut ctx = Context::new(args); + apply_update_parameters(&mut ctx, "transaction_id=abc").unwrap(); + assert!(ctx.args.extra.iter().any(|e| e == "custom_param=hello"), + "pre-existing extras must be preserved"); + assert!(ctx.in_transaction()); + } + + #[test] + fn test_remove_parameters_single_key() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc123,transaction_sequence_id=1").unwrap(); + assert!(ctx.in_transaction()); + + remove_parameters(&mut ctx, "transaction_id"); + assert!(!ctx.in_transaction()); + // sequence_id should still be present + assert!(ctx.args.extra.iter().any(|e| e.starts_with("transaction_sequence_id="))); + } + + #[test] + fn test_remove_parameters_multiple_keys() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc123,transaction_sequence_id=1").unwrap(); + + remove_parameters(&mut ctx, "transaction_id,transaction_sequence_id"); + assert!(!ctx.in_transaction()); + assert!(!ctx.args.extra.iter().any(|e| e.starts_with("transaction_sequence_id="))); + } + + #[test] + fn test_remove_parameters_preserves_unrelated_extras() { + let mut args = get_args().unwrap(); + args.extra = vec!["other_param=value".to_string()]; + let mut ctx = Context::new(args); + apply_update_parameters(&mut ctx, "transaction_id=abc123").unwrap(); + + remove_parameters(&mut ctx, "transaction_id"); + assert!(!ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "other_param=value"), + "unrelated extras must survive remove_parameters"); + } + + #[test] + fn test_remove_parameters_exact_prefix_match() { + // "transaction_id" removal must not affect "transaction_id_extra=..." + // because the prefix checked is "transaction_id=" which won't match. + let mut args = get_args().unwrap(); + args.extra = vec!["transaction_id_extra=value".to_string()]; + let mut ctx = Context::new(args); + apply_update_parameters(&mut ctx, "transaction_id=abc").unwrap(); + + remove_parameters(&mut ctx, "transaction_id"); + assert!(!ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_id_extra=value"), + "transaction_id_extra must not be removed when only transaction_id is removed"); + } + + #[test] + fn test_remove_parameters_empty_string_is_noop() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc").unwrap(); + let before = ctx.args.extra.clone(); + remove_parameters(&mut ctx, ""); + assert_eq!(ctx.args.extra, before); + } + + #[test] + fn test_in_transaction_lifecycle() { + let mut ctx = make_context(); + + // 1. Initially no transaction. + assert!(!ctx.in_transaction()); + + // 2. Server sends Firebolt-Update-Parameters after BEGIN. + apply_update_parameters(&mut ctx, "transaction_id=deadbeef,transaction_sequence_id=0").unwrap(); + assert!(ctx.in_transaction()); + + // 3. Sequence counter increments mid-transaction (server-side increment). + apply_update_parameters(&mut ctx, "transaction_sequence_id=1").unwrap(); + assert!(ctx.in_transaction(), "still in transaction after sequence bump"); + + // 4. Server sends Firebolt-Reset-Session after COMMIT/ROLLBACK. + remove_parameters(&mut ctx, "transaction_id,transaction_sequence_id"); + assert!(!ctx.in_transaction(), "transaction must be cleared after reset"); + + // 5. A brand-new transaction can begin. + apply_update_parameters(&mut ctx, "transaction_id=cafebabe,transaction_sequence_id=0").unwrap(); + assert!(ctx.in_transaction(), "second transaction must register correctly"); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 7bbf110..11c90a9 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -532,3 +532,179 @@ fn test_pipe_mode_continues_after_error() { assert!(stdout.contains("10"), "first query result should appear"); assert!(stdout.contains("30"), "third query result should appear despite middle failure"); } + +// ── Transaction support ─────────────────────────────────────────────────────── + +/// Helper: spawn fb in pipe mode and feed lines; return (success, stdout, stderr). +fn run_pipe(extra_args: &[&str], lines: &[&str]) -> (bool, String, String) { + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(extra_args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + for line in lines { + writeln!(stdin, "{}", line).unwrap(); + } + drop(stdin); + + let output = child.wait_with_output().unwrap(); + ( + output.status.success(), + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap(), + ) +} + +#[test] +fn test_transaction_begin_commit_succeeds() { + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 42;", "COMMIT;"], + ); + assert!(ok, "BEGIN/COMMIT sequence should exit 0; stderr: {}", stderr); + assert!(stdout.contains("42"), "SELECT inside transaction should return data"); +} + +#[test] +fn test_transaction_begin_rollback_succeeds() { + let (ok, _stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 1;", "ROLLBACK;"], + ); + assert!(ok, "BEGIN/ROLLBACK sequence should exit 0; stderr: {}", stderr); +} + +#[test] +fn test_transaction_id_appears_in_url_after_begin() { + // --verbose prints the updated URL after each response. + // After BEGIN the server adds Firebolt-Update-Parameters, so the next + // URL (printed by the verbose handler) must contain transaction_id. + let (ok, _stdout, stderr) = run_pipe( + &["--core", "--verbose", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 1;", "COMMIT;"], + ); + assert!(ok, "sequence should succeed; stderr: {}", stderr); + assert!( + stderr.contains("transaction_id"), + "URL emitted after BEGIN must contain transaction_id; stderr: {}", + stderr + ); +} + +#[test] +fn test_transaction_id_absent_from_url_after_commit() { + // After COMMIT the transaction_id must be removed from the URL so that + // subsequent queries don't carry a stale transaction context. + let (ok, stdout, stderr) = run_pipe( + &["--core", "--verbose", "-f", "TabSeparatedWithNamesAndTypes"], + // Extra SELECT after COMMIT: its URL line must not carry transaction_id. + &["BEGIN;", "SELECT 1;", "COMMIT;", "SELECT 99;"], + ); + assert!(ok, "full sequence should succeed; stderr: {}", stderr); + assert!(stdout.contains("99"), "SELECT after COMMIT should produce output"); + + // Find all "URL:" lines that appear after the last "COMMIT" reference. + // The URL for the post-COMMIT SELECT must not carry transaction_id. + let lines: Vec<&str> = stderr.lines().collect(); + let commit_pos = lines.iter().rposition(|l| l.contains("COMMIT")); + if let Some(pos) = commit_pos { + let post_commit_urls: Vec<&str> = lines[pos..] + .iter() + .filter(|l| l.starts_with("URL:")) + .copied() + .collect(); + assert!( + !post_commit_urls.is_empty(), + "expected at least one URL line after COMMIT" + ); + for url_line in &post_commit_urls { + assert!( + !url_line.contains("transaction_id"), + "URL after COMMIT must not carry transaction_id: {}", + url_line + ); + } + } +} + +#[test] +fn test_transaction_id_absent_from_url_after_rollback() { + let (ok, stdout, stderr) = run_pipe( + &["--core", "--verbose", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 1;", "ROLLBACK;", "SELECT 77;"], + ); + assert!(ok, "full sequence should succeed; stderr: {}", stderr); + assert!(stdout.contains("77"), "SELECT after ROLLBACK should produce output"); + + let lines: Vec<&str> = stderr.lines().collect(); + let rollback_pos = lines.iter().rposition(|l| l.contains("ROLLBACK")); + if let Some(pos) = rollback_pos { + let post_rollback_urls: Vec<&str> = lines[pos..] + .iter() + .filter(|l| l.starts_with("URL:")) + .copied() + .collect(); + for url_line in &post_rollback_urls { + assert!( + !url_line.contains("transaction_id"), + "URL after ROLLBACK must not carry transaction_id: {}", + url_line + ); + } + } +} + +#[test] +fn test_transaction_dml_commit() { + // Full DML cycle: insert inside transaction, commit, verify data persists. + let table = format!("test_tx_commit_{}", std::process::id()); + let create = format!("CREATE TABLE {} (x INT);", table); + let insert = format!("INSERT INTO {} VALUES (1234);", table); + let select = format!("SELECT x FROM {};", table); + let drop = format!("DROP TABLE {};", table); + + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &[&create, "BEGIN;", &insert, "COMMIT;", &select, &drop], + ); + assert!(ok, "DML commit sequence should succeed; stderr: {}", stderr); + assert!(stdout.contains("1234"), "committed row must be visible after COMMIT"); +} + +#[test] +fn test_transaction_dml_rollback() { + // Insert inside a transaction then roll back — the row must not persist. + let table = format!("test_tx_rollback_{}", std::process::id()); + let create = format!("CREATE TABLE {} (x INT);", table); + let insert = format!("INSERT INTO {} VALUES (9999);", table); + let select = format!("SELECT x FROM {};", table); + let drop = format!("DROP TABLE {};", table); + + // All steps in one session: CREATE, BEGIN, INSERT, ROLLBACK, SELECT, DROP. + // The SELECT runs after the ROLLBACK so its output must not contain 9999. + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &[&create, "BEGIN;", &insert, "ROLLBACK;", &select, &drop], + ); + assert!(ok, "DML rollback sequence should succeed; stderr: {}", stderr); + assert!(!stdout.contains("9999"), "rolled-back row must not appear after ROLLBACK"); +} + +#[test] +fn test_transaction_sequential_transactions() { + // Two independent transactions back-to-back on the same connection. + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &[ + "BEGIN;", "SELECT 11;", "COMMIT;", + "BEGIN;", "SELECT 22;", "COMMIT;", + ], + ); + assert!(ok, "sequential transactions should succeed; stderr: {}", stderr); + assert!(stdout.contains("11"), "first transaction result missing"); + assert!(stdout.contains("22"), "second transaction result missing"); +} From 7b4ba801012ed6b6f9e3103cc5c102de9576ac1d Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 24 Feb 2026 18:19:35 +0100 Subject: [PATCH 115/147] Fix two TUI regressions after BEGIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. push_custom_settings: exclude transaction_id and transaction_sequence_id from the "Settings: ..." echo line. These are server-managed internal params, not user-visible settings. 2. Schema refresh: suppress the DDL-triggered schema cache refresh when a transaction is open. BEGIN returns zero columns (same DDL signal as CREATE TABLE), causing a schema query to fire inside the open transaction — which then fails. The refresh defers naturally to after COMMIT/ROLLBACK, which is correct since BEGIN doesn't change any schema. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index fc5067e..fdaf8fb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -704,7 +704,14 @@ impl TuiApp { // schema cache so new/dropped tables appear in completion. // The flag is only set on success (ParsedResult is only // emitted when the query completed without errors). - if self.pending_schema_refresh && !self.context.args.no_completion { + // Skip the refresh when a transaction is open: BEGIN also + // returns zero columns, and schema queries sent inside an + // open transaction produce errors on some backends. The + // refresh will happen naturally after COMMIT/ROLLBACK. + if self.pending_schema_refresh + && !self.context.args.no_completion + && !self.context.in_transaction() + { self.pending_schema_refresh = false; let cache = self.schema_cache.clone(); let mut ctx_clone = self.context.clone(); @@ -2088,6 +2095,9 @@ impl TuiApp { && !e.starts_with("query_label=") && !e.starts_with("advanced_mode=") && !e.starts_with("output_format=") + // Server-managed transaction params — never show to user + && !e.starts_with("transaction_id=") + && !e.starts_with("transaction_sequence_id=") }) .map(|e| url_decode_setting(e)) .collect(); From 7920e405d66298c7faba6622111afe6e2d83b43e Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 09:13:15 +0100 Subject: [PATCH 116/147] Isolate internal queries from open transactions Add Context::without_transaction() which returns a clone with transaction_id and transaction_sequence_id stripped from args.extra and the URL rebuilt. Use it in all three places that fire internal queries: - schema cache auto-refresh (DDL-triggered, drain_query_output) - schema cache manual refresh (/refresh command, do_refresh) - setting validation (validate_setting in execute_queries) This also removes the earlier in_transaction() guard on the DDL schema refresh, which is no longer needed since the context passed to refresh() is now always transaction-free. Co-Authored-By: Claude Sonnet 4.6 --- src/context.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 11 ++++------- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/context.rs b/src/context.rs index 996d53f..274aa73 100644 --- a/src/context.rs +++ b/src/context.rs @@ -149,6 +149,21 @@ impl Context { pub fn is_tui(&self) -> bool { self.tui_output_tx.is_some() } + + /// Returns a clone with all server-managed transaction parameters removed. + /// + /// Use this when creating a context for internal queries (schema cache + /// refresh, setting validation) that must not run inside an open + /// transaction. + pub fn without_transaction(&self) -> Self { + let mut c = self.clone(); + c.args.extra.retain(|e| { + !e.starts_with("transaction_id=") + && !e.starts_with("transaction_sequence_id=") + }); + c.update_url(); + c + } } #[cfg(test)] @@ -168,4 +183,37 @@ mod tests { assert!(context.sa_token.is_none()); assert!(context.last_result.is_none()); } + + #[test] + fn test_without_transaction_strips_txn_params() { + let mut args = crate::args::get_args().unwrap(); + args.extra = vec![ + "transaction_id=deadbeef".to_string(), + "transaction_sequence_id=3".to_string(), + "other_param=keep_me".to_string(), + ]; + let ctx = Context::new(args); + assert!(ctx.in_transaction()); + + let clean = ctx.without_transaction(); + + assert!(!clean.in_transaction()); + assert!(!clean.args.extra.iter().any(|e| e.starts_with("transaction_sequence_id="))); + assert!(clean.args.extra.iter().any(|e| e == "other_param=keep_me"), + "non-transaction extras must be preserved"); + // URL must reflect the stripped state + assert!(!clean.url.contains("transaction_id"), + "URL must not contain transaction_id after without_transaction"); + } + + #[test] + fn test_without_transaction_noop_when_no_txn() { + let mut args = crate::args::get_args().unwrap(); + args.extra = vec!["custom=value".to_string()]; + let ctx = Context::new(args); + assert!(!ctx.in_transaction()); + + let clean = ctx.without_transaction(); + assert_eq!(clean.args.extra, ctx.args.extra); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index fdaf8fb..e9a4904 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -708,13 +708,10 @@ impl TuiApp { // returns zero columns, and schema queries sent inside an // open transaction produce errors on some backends. The // refresh will happen naturally after COMMIT/ROLLBACK. - if self.pending_schema_refresh - && !self.context.args.no_completion - && !self.context.in_transaction() - { + if self.pending_schema_refresh && !self.context.args.no_completion { self.pending_schema_refresh = false; let cache = self.schema_cache.clone(); - let mut ctx_clone = self.context.clone(); + let mut ctx_clone = self.context.without_transaction(); tokio::spawn(async move { let _ = cache.refresh(&mut ctx_clone).await; }); @@ -2044,7 +2041,7 @@ impl TuiApp { return; } let cache = self.schema_cache.clone(); - let mut ctx_clone = self.context.clone(); + let mut ctx_clone = self.context.without_transaction(); self.output.push_line("Refreshing schema cache..."); tokio::spawn(async move { if let Err(e) = cache.refresh(&mut ctx_clone).await { @@ -2112,7 +2109,7 @@ impl TuiApp { // them by sending SELECT 1 with the new settings; bail out on rejection. for q in &queries { let extra_before = self.context.args.extra.clone(); - let mut test_ctx = self.context.clone(); + let mut test_ctx = self.context.without_transaction(); if set_args(&mut test_ctx, q).unwrap_or(false) { if test_ctx.args.extra != extra_before { // Server-side parameter: validate before applying From 1411195e6ebd3213b972ac9330ad613c87d46439 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 09:17:03 +0100 Subject: [PATCH 117/147] Remove stale BEGIN schema-refresh comment The in_transaction() guard was already dropped in the previous commit (without_transaction() makes the refresh safe regardless of whether a transaction is open). Remove the now-incorrect comment that still described that old special case. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e9a4904..9e111af 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -704,10 +704,6 @@ impl TuiApp { // schema cache so new/dropped tables appear in completion. // The flag is only set on success (ParsedResult is only // emitted when the query completed without errors). - // Skip the refresh when a transaction is open: BEGIN also - // returns zero columns, and schema queries sent inside an - // open transaction produce errors on some backends. The - // refresh will happen naturally after COMMIT/ROLLBACK. if self.pending_schema_refresh && !self.context.args.no_completion { self.pending_schema_refresh = false; let cache = self.schema_cache.clone(); From 28ac60eed0137eb37283ac5e93d5f3a310ace0f2 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 09:37:56 +0100 Subject: [PATCH 118/147] Fix issues found in code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tui/mod.rs: - slash commands: call history.reset_navigation() at entry so Up/Down navigation is consistent after /run, /benchmark, /watch, etc. - execute_queries: fix extra_before baseline — capture it from the already-stripped test_ctx instead of self.context so the "did this set command add a new parameter" check isn't confused by transaction params being present in one but absent in the other - complete_file_paths: replace unwrap() fallback on read_dir(".") with a graceful return of an empty Vec, preventing a panic if the working directory becomes unreadable - do_refresh: route schema refresh errors through context.emit_err() instead of eprintln! so they appear in the TUI output pane rather than corrupting the terminal schema_cache.rs: - do_refresh: all warning/error messages now go through context.emit_err() so they are displayed correctly in TUI mode viewer.rs: - early-return conditions (no result, query errors, empty columns/rows) now return Err() so run_viewer can show them as flash messages instead of printing to the suspended terminal - delete the temp CSV file after csvlens exits - update tests to assert is_err() for the early-return cases query.rs: - emit "^C" to the output channel when a query is cancelled so the TUI output pane shows a visible cancellation indicator args.rs: - replace format!("&output_format=JSONLines_Compact") with a plain string (no interpolation arguments) CLAUDE.md: - replace stale rustyline/Ctrl+O/Ctrl+V descriptions with accurate ratatui TUI behavior Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 13 +++++----- src/args.rs | 2 +- src/completion/schema_cache.rs | 16 ++++++------ src/query.rs | 1 + src/tui/mod.rs | 13 +++++----- src/viewer.rs | 46 ++++++++++++---------------------- 6 files changed, 41 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f82b03..d6b1db1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ cargo clippy ### Module Structure -- **main.rs**: Entry point, handles REPL mode with rustyline for line editing and history +- **main.rs**: Entry point; routes to headless (single query / pipe) or interactive TUI mode +- **tui/mod.rs**: Interactive TUI built with ratatui + tui-textarea; event loop, key handling, slash commands, completion, history - **args.rs**: Command-line argument parsing with gumdrop, URL generation, and default config management - **context.rs**: Application state container holding Args and ServiceAccountToken - **query.rs**: HTTP query execution, set/unset parameter handling, and SQL query splitting using Pest parser @@ -196,9 +197,9 @@ fb-cli uses a single `--format` option with a prefix notation to distinguish bet ## Important Behavioral Details - **URL encoding**: Parameters are encoded once during `normalize_extras(encode: true)`. Subsequent calls with `encode: false` prevent double-encoding. -- **REPL multi-line**: Press Ctrl+O to insert newline. Queries must end with semicolon. -- **Ctrl+C in REPL**: Cancels current input but doesn't exit -- **Ctrl+D in REPL**: Exits (EOF) -- **Ctrl+V in REPL**: Inserts `\view` command (press Enter to execute and open csvlens viewer for last result) -- **Spinner**: Shown during query execution unless `--no-spinner` or `--concise` +- **TUI multi-line**: Press Shift+Enter or Alt+Enter to insert a newline. Queries must end with semicolon. +- **Ctrl+C in TUI**: Cancels an in-flight query; does not exit +- **Ctrl+D in TUI**: Exits +- **Ctrl+V in TUI**: Opens csvlens viewer for the last result directly +- **Spinner**: Shown during query execution for client-side formats only - **History**: Saved to `~/.firebolt/fb_history` (max 10,000 entries), supports Ctrl+R search diff --git a/src/args.rs b/src/args.rs index a603552..4553ca3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -300,7 +300,7 @@ pub fn get_url(args: &Args) -> String { let output_format = if !args.format.is_empty() && !args.extra.iter().any(|e| e.starts_with("format=")) { if args.format.starts_with("client:") { // Client-side rendering: always use JSONLines_Compact - format!("&output_format=JSONLines_Compact") + "&output_format=JSONLines_Compact".to_string() } else { // Server-side rendering: use format as-is format!("&output_format={}", &args.format) diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index d621030..8c9e6a1 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -399,12 +399,14 @@ impl SchemaCache { ); } } else { - eprintln!("Warning: Failed to parse tables from schema query"); - eprintln!("Output was: {}", &tables_output[..tables_output.len().min(500)]); + context.emit_err(format!( + "Warning: Failed to parse tables from schema query. Output: {}", + &tables_output[..tables_output.len().min(200)] + )); } } Err(e) => { - eprintln!("Warning: Tables query failed: {}", e); + context.emit_err(format!("Warning: Tables query failed: {}", e)); } } @@ -427,11 +429,11 @@ impl SchemaCache { }); } } else { - eprintln!("Warning: Failed to parse columns from schema query"); + context.emit_err("Warning: Failed to parse columns from schema query".to_string()); } } Err(e) => { - eprintln!("Warning: Columns query failed: {}", e); + context.emit_err(format!("Warning: Columns query failed: {}", e)); } } @@ -444,11 +446,11 @@ impl SchemaCache { if let Some(function_list) = Self::parse_functions(&functions_output) { *self.functions.write().unwrap() = function_list.into_iter().collect(); } else { - eprintln!("Warning: Failed to parse functions from schema query"); + context.emit_err("Warning: Failed to parse functions from schema query".to_string()); } } Err(e) => { - eprintln!("Warning: Functions query failed: {}", e); + context.emit_err(format!("Warning: Functions query failed: {}", e)); } } diff --git a/src/query.rs b/src/query.rs index 601def5..4b2b620 100644 --- a/src/query.rs +++ b/src/query.rs @@ -486,6 +486,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let _ = signal::ctrl_c().await; } } => { + out_err!(context, "^C"); error_kind = Some(ErrorKind::SystemError); } response = async_resp => { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9e111af..e954f93 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -254,10 +254,9 @@ fn complete_file_paths(partial: &str) -> Vec (parent, fname) }; - let read_dir = std::fs::read_dir(dir).unwrap_or_else(|_| { - // If dir doesn't exist yet, try "." so we still return something - std::fs::read_dir(".").unwrap() - }); + let Ok(read_dir) = std::fs::read_dir(dir).or_else(|_| std::fs::read_dir(".")) else { + return Vec::new(); + }; let mut dirs_list: Vec = Vec::new(); let mut files_list: Vec = Vec::new(); @@ -1587,6 +1586,8 @@ impl TuiApp { // ── Slash commands ──────────────────────────────────────────────────────── async fn handle_slash_command(&mut self, cmd: &str) { + self.history.reset_navigation(); + // /exit and /quit if cmd == "/exit" || cmd == "/quit" { self.should_quit = true; @@ -2041,7 +2042,7 @@ impl TuiApp { self.output.push_line("Refreshing schema cache..."); tokio::spawn(async move { if let Err(e) = cache.refresh(&mut ctx_clone).await { - eprintln!("Failed to refresh schema cache: {}", e); + ctx_clone.emit_err(format!("Schema refresh failed: {}", e)); } }); } @@ -2104,8 +2105,8 @@ impl TuiApp { // that change server-side parameters (i.e. modify args.extra), first validate // them by sending SELECT 1 with the new settings; bail out on rejection. for q in &queries { - let extra_before = self.context.args.extra.clone(); let mut test_ctx = self.context.without_transaction(); + let extra_before = test_ctx.args.extra.clone(); if set_args(&mut test_ctx, q).unwrap_or(false) { if test_ctx.args.extra != extra_before { // Server-side parameter: validate before applying diff --git a/src/viewer.rs b/src/viewer.rs index 73d2d88..343e69b 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -13,30 +13,22 @@ pub fn open_csvlens_viewer(context: &Context) -> Result<(), Box r, - None => { - eprintln!("No query results to display. Run a query first."); - return Ok(()); - } + None => return Err("No query results to display. Run a query first.".into()), }; // Check for errors in last result if let Some(ref errors) = result.errors { - eprintln!("Cannot display results - last query had errors:"); - for error in errors { - eprintln!(" {}", error.description); - } - return Ok(()); + let msgs: Vec<&str> = errors.iter().map(|e| e.description.as_str()).collect(); + return Err(format!("Cannot display results — last query had errors: {}", msgs.join("; ")).into()); } // Check if result is empty if result.columns.is_empty() { - eprintln!("No data to display (no columns in result)."); - return Ok(()); + return Err("No data to display (no columns in result).".into()); } if result.rows.is_empty() { - eprintln!("Query returned 0 rows. Nothing to display."); - return Ok(()); + return Err("Query returned 0 rows. Nothing to display.".into()); } // Write result to temporary CSV file @@ -45,11 +37,6 @@ pub fn open_csvlens_viewer(context: &Context) -> Result<(), Box Result<(), Box Ok(()), - Err(e) => { - eprintln!("Error opening csvlens: {}", e); - Err(Box::new(e)) - } - } + let result = csvlens::run_csvlens_with_options(options); + let _ = std::fs::remove_file(&csv_path); + result.map(|_| ()).map_err(|e| Box::new(e) as Box) } #[cfg(test)] @@ -78,9 +61,9 @@ mod tests { args.format = String::from("client:auto"); let context = Context::new(args); - // Should not panic, should return Ok with error message let result = open_csvlens_viewer(&context); - assert!(result.is_ok()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No query results")); } #[test] @@ -98,7 +81,8 @@ mod tests { }); let result = open_csvlens_viewer(&context); - assert!(result.is_ok()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Test error")); } #[test] @@ -114,7 +98,8 @@ mod tests { }); let result = open_csvlens_viewer(&context); - assert!(result.is_ok()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no columns")); } #[test] @@ -133,7 +118,8 @@ mod tests { }); let result = open_csvlens_viewer(&context); - assert!(result.is_ok()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("0 rows")); } #[test] From dddf954ef1735caf41baf74e6dcf23c84198d516 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 09:49:05 +0100 Subject: [PATCH 119/147] Fix TUI corruption from bg tasks; add red disconnected indicator + reconnect pinger - Set context.tui_output_tx = Some(bg_tx) in TuiApp::new() so all cloned contexts (schema refresh, do_refresh) inherit a real sender instead of falling back to eprintln! and corrupting the display - Add bg_rx drain in event loop: routes Warning:/Error: lines to output pane - Add ConnectionStatus(bool) TuiMsg variant emitted by every schema refresh - On ConnectionStatus(false): show host/db in red in the status bar and spawn a background pinger (SELECT 1, once per second) until reconnected - On ConnectionStatus(true): restore green indicator, stop pinger, trigger a silent schema refresh to populate completions after reconnection - schema_cache::do_refresh now returns Err when the tables query fails so callers can distinguish "server unreachable" from partial failures Co-Authored-By: Claude Sonnet 4.6 --- src/completion/schema_cache.rs | 4 +- src/tui/mod.rs | 162 ++++++++++++++++++++++++++++----- src/tui_msg.rs | 3 + 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 8c9e6a1..0077faf 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -383,7 +383,8 @@ impl SchemaCache { // Parse and populate cache let mut new_tables = HashMap::new(); - // Parse tables + // Parse tables — return Err on network/connection failure so the caller + // can distinguish "server unreachable" from other issues. match tables_result { Ok(tables_output) => { if let Some(table_list) = Self::parse_tables(&tables_output) { @@ -407,6 +408,7 @@ impl SchemaCache { } Err(e) => { context.emit_err(format!("Warning: Tables query failed: {}", e)); + return Err(e); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e954f93..79396fb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -474,6 +474,16 @@ pub struct TuiApp { should_quit: bool, pub has_error: bool, + /// Persistent background channel: schema refresh tasks and background workers + /// route their output here instead of to stderr. Drained every event-loop tick. + bg_rx: mpsc::UnboundedReceiver, + + /// Whether the server was successfully reached during the last schema refresh. + /// False until the first successful refresh; drives the red status-bar indicator. + connected: bool, + /// True while a reconnection-pinger task is running (prevents duplicate pingers). + ping_active: bool, + /// Temporary message shown in the status bar, with the time it was set. flash_message: Option<(String, Instant)>, @@ -486,7 +496,7 @@ pub struct TuiApp { } impl TuiApp { - pub fn new(context: Context, schema_cache: Arc) -> Self { + pub fn new(mut context: Context, schema_cache: Arc) -> Self { let usage_tracker = context .usage_tracker .clone() @@ -507,6 +517,11 @@ impl TuiApp { }); let fuzzy_completer = FuzzyCompleter::new(schema_cache.clone(), usage_tracker); + // Background channel: background tasks (schema refresh, etc.) send output + // here so it reaches the TUI output pane rather than going to stderr. + let (bg_tx, bg_rx) = mpsc::unbounded_channel::(); + context.tui_output_tx = Some(bg_tx); + Self { context, textarea, @@ -539,6 +554,9 @@ impl TuiApp { flash_message: None, pending_viewer: false, pending_editor: false, + bg_rx, + connected: false, + ping_active: false, } } @@ -571,8 +589,9 @@ impl TuiApp { let cache = self.schema_cache.clone(); let mut ctx_clone = self.context.clone(); tokio::spawn(async move { - if let Err(_e) = cache.refresh(&mut ctx_clone).await { - // silently ignore – completion just won't work + let ok = cache.refresh(&mut ctx_clone).await.is_ok(); + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(ok)); } }); } @@ -605,6 +624,7 @@ impl TuiApp { } self.drain_query_output(); + self.drain_bg_output(); if self.is_running { self.spinner_tick += 1; @@ -677,6 +697,10 @@ impl TuiApp { Ok(TuiMsg::StyledLines(lines)) => { self.output.push_tui_lines(lines); } + Ok(TuiMsg::ConnectionStatus(_)) => { + // ConnectionStatus is handled by drain_bg_output; shouldn't + // arrive on the query channel, but ignore gracefully. + } Ok(TuiMsg::Line(line)) => { // Capture the "Showing first N rows..." hint for the running pane only. if self.is_running && line.starts_with("Showing first ") { @@ -708,7 +732,10 @@ impl TuiApp { let cache = self.schema_cache.clone(); let mut ctx_clone = self.context.without_transaction(); tokio::spawn(async move { - let _ = cache.refresh(&mut ctx_clone).await; + let ok = cache.refresh(&mut ctx_clone).await.is_ok(); + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(ok)); + } }); } @@ -718,6 +745,80 @@ impl TuiApp { } } + // ── Background output draining ─────────────────────────────────────────── + + /// Drain the persistent background channel (schema refresh warnings, connection + /// status updates). Called every event-loop tick regardless of query state. + fn drain_bg_output(&mut self) { + loop { + match self.bg_rx.try_recv() { + Ok(TuiMsg::ConnectionStatus(ok)) => { + if ok { + let was_disconnected = !self.connected; + self.connected = true; + self.ping_active = false; + if was_disconnected && !self.context.args.no_completion { + // Reconnected — trigger a schema refresh. + // This refresh does NOT send ConnectionStatus so we + // don't create a feedback loop. + let cache = self.schema_cache.clone(); + let mut ctx = self.context.without_transaction(); + // Suppress output routing for the reconnect refresh + // (errors are unlikely; if they happen the next DDL + // will retry automatically). + ctx.tui_output_tx = None; + tokio::spawn(async move { + let _ = cache.refresh(&mut ctx).await; + }); + } + } else { + self.connected = false; + if !self.ping_active { + self.ping_active = true; + self.spawn_pinger(); + } + } + } + Ok(TuiMsg::Line(line)) => { + if line.starts_with("Warning:") || line.starts_with("Error:") { + self.output.push_error(&line); + } else { + self.output.push_ansi_text(&line); + } + } + Ok(TuiMsg::StyledLines(lines)) => { + self.output.push_tui_lines(lines); + } + Ok(_) => {} // other msg types not expected on bg channel + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => break, + } + } + } + + /// Spawn a background reconnection-pinger that sends `SELECT 1` once per + /// second until the server responds, then sends `ConnectionStatus(true)` and exits. + fn spawn_pinger(&self) { + // Capture the bg_tx so the pinger can signal back. + let bg_tx = match &self.context.tui_output_tx { + Some(tx) => tx.clone(), + None => return, // shouldn't happen, but be safe + }; + let mut ctx = self.context.without_transaction(); + // Suppress all query output from the pinger — connection-refused errors + // every second would be very noisy. + ctx.tui_output_tx = None; + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + if crate::query::query_silent(&mut ctx, "SELECT 1").await.is_ok() { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); + return; + } + } + }); + } + // ── Key handling ───────────────────────────────────────────────────────── /// Returns `true` when the app should exit. @@ -2041,8 +2142,9 @@ impl TuiApp { let mut ctx_clone = self.context.without_transaction(); self.output.push_line("Refreshing schema cache..."); tokio::spawn(async move { - if let Err(e) = cache.refresh(&mut ctx_clone).await { - ctx_clone.emit_err(format!("Schema refresh failed: {}", e)); + let ok = cache.refresh(&mut ctx_clone).await.is_ok(); + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(ok)); } }); } @@ -2880,25 +2982,37 @@ impl TuiApp { Span::styled(right, Style::default().bg(Color::DarkGray).fg(Color::White)), ]; Paragraph::new(Line::from(spans)) - } else if in_txn { - // Transaction active: show a yellow "TXN" badge between conn info and hints. - let badge = " TXN "; - let pad = total.saturating_sub(conn_info.len() + badge.len() + right.len()); - let base = Style::default().bg(Color::DarkGray).fg(Color::White); - let txn_style = Style::default() - .bg(Color::Indexed(130)) // dark orange - .fg(Color::White) - .add_modifier(Modifier::BOLD); - let spans: Vec = vec![ - Span::styled(format!("{}{}", conn_info, " ".repeat(pad)), base), - Span::styled(badge, txn_style), - Span::styled(right, base), - ]; - Paragraph::new(Line::from(spans)) } else { - let pad = total.saturating_sub(conn_info.len() + right.len()); - let text = format!("{}{}{}", conn_info, " ".repeat(pad), right); - Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)) + let base = Style::default().bg(Color::DarkGray).fg(Color::White); + // Show connection info in red when the server is unreachable. + let conn_style = if !self.connected && !self.context.args.no_completion { + Style::default().bg(Color::Red).fg(Color::White) + } else { + base + }; + + if in_txn { + // Transaction active: show a yellow "TXN" badge between conn info and hints. + let badge = " TXN "; + let pad = total.saturating_sub(conn_info.len() + badge.len() + right.len()); + let txn_style = Style::default() + .bg(Color::Indexed(130)) // dark orange + .fg(Color::White) + .add_modifier(Modifier::BOLD); + let spans: Vec = vec![ + Span::styled(format!("{}{}", conn_info, " ".repeat(pad)), conn_style), + Span::styled(badge, txn_style), + Span::styled(right, base), + ]; + Paragraph::new(Line::from(spans)) + } else { + let pad = total.saturating_sub(conn_info.len() + right.len()); + let spans: Vec = vec![ + Span::styled(format!("{}{}", conn_info, " ".repeat(pad)), conn_style), + Span::styled(right, base), + ]; + Paragraph::new(Line::from(spans)) + } }; f.render_widget(status, area); } diff --git a/src/tui_msg.rs b/src/tui_msg.rs index 98c2f8b..b455300 100644 --- a/src/tui_msg.rs +++ b/src/tui_msg.rs @@ -15,6 +15,9 @@ pub enum TuiMsg { /// or `firebolt-reset-session` response headers are received so that the /// transaction badge (and future features) reflect the updated state. ParamUpdate(Vec), + /// Report whether a schema-cache refresh (or ping) succeeded. + /// `true` = server reachable; `false` = connection failed. + ConnectionStatus(bool), } /// A single rendered line made up of zero or more styled spans. From ddbe464622add99a79cc69ad88a494dbcfa80340 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 09:56:18 +0100 Subject: [PATCH 120/147] Fix reconnect refresh routing errors through bg channel instead of eprintln! Removed the erroneous ctx.tui_output_tx = None that was stripping the output channel from the reconnect-triggered schema refresh. When the server starts up partially (e.g. "Cluster not yet healthy"), the pinger's SELECT 1 would succeed triggering a reconnect refresh, but schema warnings would fall back to eprintln! and corrupt the TUI display. Now all warnings from reconnect refresh appear in the output pane like other background errors. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 79396fb..4737703 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -759,14 +759,11 @@ impl TuiApp { self.ping_active = false; if was_disconnected && !self.context.args.no_completion { // Reconnected — trigger a schema refresh. - // This refresh does NOT send ConnectionStatus so we - // don't create a feedback loop. + // Keep tui_output_tx so any warnings appear in the output pane. + // Do NOT send ConnectionStatus from this refresh to avoid a + // feedback loop (server reachability is already confirmed by ping). let cache = self.schema_cache.clone(); let mut ctx = self.context.without_transaction(); - // Suppress output routing for the reconnect refresh - // (errors are unlikely; if they happen the next DDL - // will retry automatically). - ctx.tui_output_tx = None; tokio::spawn(async move { let _ = cache.refresh(&mut ctx).await; }); From 9b422283a6f4c65a5de2c55e156ed2e5a30f62a1 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 10:01:34 +0100 Subject: [PATCH 121/147] Reconnect: trigger schema refresh + show 'No server connection' in footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Initialize connected=true (optimistic); avoids spurious double-refresh when the startup refresh succeeds on the first try - When pinger detects reconnection, emit "Reconnected. Refreshing schema cache..." to the output pane before spawning the schema refresh task - Footer now shows " ✗ No server connection" appended to the red host|db span so the disconnected state is unambiguous Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4737703..ef68655 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -555,7 +555,7 @@ impl TuiApp { pending_viewer: false, pending_editor: false, bg_rx, - connected: false, + connected: true, // assume connected; turns red on first ConnectionStatus(false) ping_active: false, } } @@ -758,10 +758,12 @@ impl TuiApp { self.connected = true; self.ping_active = false; if was_disconnected && !self.context.args.no_completion { - // Reconnected — trigger a schema refresh. + // Reconnected — trigger a schema refresh so auto-completion + // reflects the current database state. // Keep tui_output_tx so any warnings appear in the output pane. // Do NOT send ConnectionStatus from this refresh to avoid a // feedback loop (server reachability is already confirmed by ping). + self.output.push_line("Reconnected. Refreshing schema cache..."); let cache = self.schema_cache.clone(); let mut ctx = self.context.without_transaction(); tokio::spawn(async move { @@ -2981,31 +2983,33 @@ impl TuiApp { Paragraph::new(Line::from(spans)) } else { let base = Style::default().bg(Color::DarkGray).fg(Color::White); - // Show connection info in red when the server is unreachable. - let conn_style = if !self.connected && !self.context.args.no_completion { - Style::default().bg(Color::Red).fg(Color::White) + let disconnected = !self.connected && !self.context.args.no_completion; + // When disconnected: show conn_info + "No server connection" badge in red. + let (left_text, conn_style) = if disconnected { + let disconnected_label = format!("{} \u{2717} No server connection ", conn_info); + (disconnected_label, Style::default().bg(Color::Red).fg(Color::White)) } else { - base + (conn_info.clone(), base) }; if in_txn { // Transaction active: show a yellow "TXN" badge between conn info and hints. let badge = " TXN "; - let pad = total.saturating_sub(conn_info.len() + badge.len() + right.len()); + let pad = total.saturating_sub(left_text.len() + badge.len() + right.len()); let txn_style = Style::default() .bg(Color::Indexed(130)) // dark orange .fg(Color::White) .add_modifier(Modifier::BOLD); let spans: Vec = vec![ - Span::styled(format!("{}{}", conn_info, " ".repeat(pad)), conn_style), + Span::styled(format!("{}{}", left_text, " ".repeat(pad)), conn_style), Span::styled(badge, txn_style), Span::styled(right, base), ]; Paragraph::new(Line::from(spans)) } else { - let pad = total.saturating_sub(conn_info.len() + right.len()); + let pad = total.saturating_sub(left_text.len() + right.len()); let spans: Vec = vec![ - Span::styled(format!("{}{}", conn_info, " ".repeat(pad)), conn_style), + Span::styled(format!("{}{}", left_text, " ".repeat(pad)), conn_style), Span::styled(right, base), ]; Paragraph::new(Line::from(spans)) From 71365692edf882b0c22c9b8c73055feffc212a58 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 10:15:03 +0100 Subject: [PATCH 122/147] Use ping_server (SELECT 42 + error-body check) before schema refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the pinger used query_silent("SELECT 1") which returns Ok for any HTTP 200 response, including {"errors": [{"description": "Cluster not yet healthy"}]}. This caused premature ConnectionStatus(true) during cluster startup. - Add ping_server() to query.rs: sends SELECT 42 via query_silent, then scans the response body for a top-level "errors" key — returns Err if found - Startup schema refresh: ping_server first; only run cache.refresh() on Ok, otherwise send ConnectionStatus(false) and let the pinger handle it - Pinger: replace query_silent with ping_server for the same reason The schema refresh is now only attempted once the server can actually execute queries, not merely accept TCP connections or return HTTP 200 with error JSON. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 16 ++++++++++++++++ src/tui/mod.rs | 24 +++++++++++++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/query.rs b/src/query.rs index 4b2b620..76a8bf9 100644 --- a/src/query.rs +++ b/src/query.rs @@ -312,6 +312,22 @@ pub async fn query_silent(context: &mut Context, query_text: &str) -> Result Result<(), Box> { + let body = query_silent(context, "SELECT 42").await?; + for line in body.lines() { + if let Ok(json) = serde_json::from_str::(line) { + if json.get("errors").is_some() { + return Err("Server not ready".into()); + } + } + } + Ok(()) +} + /// Send `SELECT 1;` with the current context URL to validate that all query /// parameters are accepted by the server. Returns an error message on HTTP /// 4xx/5xx or connection failure. diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ef68655..5248c47 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -584,14 +584,25 @@ impl TuiApp { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Kick off background schema cache refresh + // Kick off background schema cache refresh — but only after confirming + // the server can actually execute queries (ping_server checks for error + // responses, unlike a bare TCP connect or HTTP 200 with error JSON). if !self.context.args.no_completion { let cache = self.schema_cache.clone(); let mut ctx_clone = self.context.clone(); tokio::spawn(async move { - let ok = cache.refresh(&mut ctx_clone).await.is_ok(); - if let Some(tx) = &ctx_clone.tui_output_tx { - let _ = tx.send(TuiMsg::ConnectionStatus(ok)); + match crate::query::ping_server(&mut ctx_clone).await { + Ok(()) => { + let _ = cache.refresh(&mut ctx_clone).await; + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(true)); + } + } + Err(_) => { + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(false)); + } + } } }); } @@ -810,7 +821,10 @@ impl TuiApp { tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(1)).await; - if crate::query::query_silent(&mut ctx, "SELECT 1").await.is_ok() { + // Use ping_server so we only consider the server "up" once it + // can actually execute a query — not merely accept a TCP connection + // or return HTTP 200 with an error body ("Cluster not yet healthy"). + if crate::query::ping_server(&mut ctx).await.is_ok() { let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); return; } From 28a179fd3a5ad3923e67feadba863fceaf732c41 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 10:22:50 +0100 Subject: [PATCH 123/147] Use cache.refresh() as the server-readiness probe; retry until schema queries succeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous ping_server(SELECT 42) approach failed because SELECT 42 is a trivial constant that the server handles immediately, while information_schema queries still fail with "Cluster not yet healthy" during startup. Changes: - schema_cache::do_refresh: run tables query first; if the response body contains a top-level "errors" key (server starting up), return Err silently without emitting warnings. Only run columns/functions/signatures queries after tables succeeds, avoiding wasted requests against an unavailable cluster. - Remove ping_server() from query.rs — no longer needed. - Replace startup spawn + separate pinger with spawn_schema_retry_loop() that calls cache.refresh() directly every 1s until it returns Ok. On first failure sends ConnectionStatus(false) (red footer); on success sends ConnectionStatus(true). A /dev/null channel suppresses repeated retry warnings from the output pane. - drain_bg_output ConnectionStatus(true): schema is already populated by the retry loop, so just show "Reconnected." without spawning an additional refresh. Co-Authored-By: Claude Sonnet 4.6 --- src/completion/schema_cache.rs | 77 +++++++++++++++------------- src/query.rs | 16 ------ src/tui/mod.rs | 93 ++++++++++++++++------------------ 3 files changed, 87 insertions(+), 99 deletions(-) diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 0077faf..f07e308 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -349,29 +349,48 @@ impl SchemaCache { } async fn do_refresh(&self, context: &mut Context) -> Result<(), Box> { - // Query tables (including system schemas - they'll be deprioritized by the scorer) + // ── Step 1: tables (health-check query) ───────────────────────────── + // Run this first and bail early on any failure so we don't waste time + // running the other three queries against an unavailable server. let tables_query = "SELECT table_schema, table_name \ FROM information_schema.tables \ ORDER BY table_schema, table_name"; - let tables_result = query_silent(context, tables_query).await; + let tables_output = match query_silent(context, tables_query).await { + Err(e) => { + // Network/connection failure — report it. + context.emit_err(format!("Warning: Tables query failed: {}", e)); + return Err(e); + } + Ok(body) => { + // The server might respond HTTP 200 but with an error body when the + // cluster is still starting up ("Cluster not yet healthy"). Treat + // that the same as a connection failure so the retry loop keeps waiting. + let is_error_body = body.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .map(|j| j.get("errors").is_some()) + .unwrap_or(false) + }); + if is_error_body { + return Err("Server not ready".into()); + } + body + } + }; - // Query columns (including system schemas - they'll be deprioritized by the scorer) + // ── Step 2: remaining queries (only reached when server is healthy) ── let columns_query = "SELECT table_schema, table_name, column_name, data_type \ FROM information_schema.columns \ ORDER BY table_schema, table_name, ordinal_position"; - let columns_result = query_silent(context, columns_query).await; - // Query functions (including system functions, excluding operators) let functions_query = "SELECT routine_name \ FROM information_schema.routines \ WHERE routine_type != 'OPERATOR' \ ORDER BY routine_name"; - let functions_result = query_silent(context, functions_query).await; - // Query function signatures from information_schema.routines. // `routine_parameters` is an array column containing the parameter types // for each overload, e.g. `["text"]` or `["anyelement","anyelement"]`. let signatures_query = "SELECT routine_name, routine_parameters \ @@ -380,36 +399,26 @@ impl SchemaCache { ORDER BY routine_name"; let signatures_result = query_silent(context, signatures_query).await; - // Parse and populate cache + // ── Step 3: parse and populate cache ──────────────────────────────── let mut new_tables = HashMap::new(); - // Parse tables — return Err on network/connection failure so the caller - // can distinguish "server unreachable" from other issues. - match tables_result { - Ok(tables_output) => { - if let Some(table_list) = Self::parse_tables(&tables_output) { - for (schema, table) in table_list { - let key = format!("{}.{}", schema, table); - new_tables.insert( - key, - TableMetadata { - schema_name: schema, - table_name: table, - columns: Vec::new(), - }, - ); - } - } else { - context.emit_err(format!( - "Warning: Failed to parse tables from schema query. Output: {}", - &tables_output[..tables_output.len().min(200)] - )); - } - } - Err(e) => { - context.emit_err(format!("Warning: Tables query failed: {}", e)); - return Err(e); + if let Some(table_list) = Self::parse_tables(&tables_output) { + for (schema, table) in table_list { + let key = format!("{}.{}", schema, table); + new_tables.insert( + key, + TableMetadata { + schema_name: schema, + table_name: table, + columns: Vec::new(), + }, + ); } + } else { + context.emit_err(format!( + "Warning: Failed to parse tables from schema query. Output: {}", + &tables_output[..tables_output.len().min(200)] + )); } // Parse columns and add to tables. diff --git a/src/query.rs b/src/query.rs index 76a8bf9..4b2b620 100644 --- a/src/query.rs +++ b/src/query.rs @@ -312,22 +312,6 @@ pub async fn query_silent(context: &mut Context, query_text: &str) -> Result Result<(), Box> { - let body = query_silent(context, "SELECT 42").await?; - for line in body.lines() { - if let Ok(json) = serde_json::from_str::(line) { - if json.get("errors").is_some() { - return Err("Server not ready".into()); - } - } - } - Ok(()) -} - /// Send `SELECT 1;` with the current context URL to validate that all query /// parameters are accepted by the server. Returns an error message on HTTP /// 4xx/5xx or connection failure. diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5248c47..97ecfbe 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -584,27 +584,12 @@ impl TuiApp { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Kick off background schema cache refresh — but only after confirming - // the server can actually execute queries (ping_server checks for error - // responses, unlike a bare TCP connect or HTTP 200 with error JSON). + // Kick off background schema cache refresh. + // spawn_schema_retry_loop uses cache.refresh() directly as the readiness + // probe — it only succeeds once all schema queries execute without errors, + // handling both network failures and "Cluster not yet healthy" responses. if !self.context.args.no_completion { - let cache = self.schema_cache.clone(); - let mut ctx_clone = self.context.clone(); - tokio::spawn(async move { - match crate::query::ping_server(&mut ctx_clone).await { - Ok(()) => { - let _ = cache.refresh(&mut ctx_clone).await; - if let Some(tx) = &ctx_clone.tui_output_tx { - let _ = tx.send(TuiMsg::ConnectionStatus(true)); - } - } - Err(_) => { - if let Some(tx) = &ctx_clone.tui_output_tx { - let _ = tx.send(TuiMsg::ConnectionStatus(false)); - } - } - } - }); + self.spawn_schema_retry_loop(true); } let result = self.event_loop(&mut terminal).await; @@ -768,24 +753,15 @@ impl TuiApp { let was_disconnected = !self.connected; self.connected = true; self.ping_active = false; - if was_disconnected && !self.context.args.no_completion { - // Reconnected — trigger a schema refresh so auto-completion - // reflects the current database state. - // Keep tui_output_tx so any warnings appear in the output pane. - // Do NOT send ConnectionStatus from this refresh to avoid a - // feedback loop (server reachability is already confirmed by ping). - self.output.push_line("Reconnected. Refreshing schema cache..."); - let cache = self.schema_cache.clone(); - let mut ctx = self.context.without_transaction(); - tokio::spawn(async move { - let _ = cache.refresh(&mut ctx).await; - }); + if was_disconnected { + // Schema was already refreshed by spawn_schema_retry_loop. + self.output.push_line("Reconnected."); } } else { self.connected = false; - if !self.ping_active { + if !self.ping_active && !self.context.args.no_completion { self.ping_active = true; - self.spawn_pinger(); + self.spawn_schema_retry_loop(false); } } } @@ -806,27 +782,46 @@ impl TuiApp { } } - /// Spawn a background reconnection-pinger that sends `SELECT 1` once per - /// second until the server responds, then sends `ConnectionStatus(true)` and exits. - fn spawn_pinger(&self) { - // Capture the bg_tx so the pinger can signal back. + /// Spawn a background task that calls `cache.refresh()` until it succeeds, + /// then sends `ConnectionStatus(true)`. Uses `cache.refresh()` directly as + /// the readiness probe — it returns `Err` for both network failures and + /// "Cluster not yet healthy" error responses, so we only signal success once + /// all schema queries have actually executed without errors. + /// + /// `first_attempt`: when `true` the first call is made immediately (no sleep); + /// when `false` (reconnect path) the first call is delayed by 1 s. + fn spawn_schema_retry_loop(&self, first_attempt: bool) { let bg_tx = match &self.context.tui_output_tx { Some(tx) => tx.clone(), - None => return, // shouldn't happen, but be safe + None => return, }; + let cache = self.schema_cache.clone(); let mut ctx = self.context.without_transaction(); - // Suppress all query output from the pinger — connection-refused errors - // every second would be very noisy. - ctx.tui_output_tx = None; + // Use a /dev/null channel so repeated retry warnings (connection refused, + // not-yet-healthy, etc.) don't flood the output pane every second. + let (devnull_tx, _devnull_rx) = mpsc::unbounded_channel::(); + ctx.tui_output_tx = Some(devnull_tx); + tokio::spawn(async move { + let mut is_first = first_attempt; + let mut sent_disconnected = false; loop { - tokio::time::sleep(Duration::from_secs(1)).await; - // Use ping_server so we only consider the server "up" once it - // can actually execute a query — not merely accept a TCP connection - // or return HTTP 200 with an error body ("Cluster not yet healthy"). - if crate::query::ping_server(&mut ctx).await.is_ok() { - let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); - return; + if !is_first { + tokio::time::sleep(Duration::from_secs(1)).await; + } + is_first = false; + + match cache.refresh(&mut ctx).await { + Ok(()) => { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); + return; + } + Err(_) => { + if !sent_disconnected { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(false)); + sent_disconnected = true; + } + } } } }); From a282e1142589e721e736014f9754eb4ddcaaf9ae Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 10:30:41 +0100 Subject: [PATCH 124/147] Fix duplicate retry loop causing premature ConnectionStatus(true) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the startup retry loop sent ConnectionStatus(false), drain_bg_output saw ping_active=false and spawned a second retry loop. The second loop immediately hit is_refreshing()=true (set by the still-running first loop) and returned Ok(()), sending ConnectionStatus(true) without populating the schema — causing "Reconnected." to appear but completions to not work. Fix: set ping_active=true before spawning the initial retry loop in run(), so drain_bg_output's ConnectionStatus(false) handler skips the duplicate spawn. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 97ecfbe..185e23e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -589,6 +589,9 @@ impl TuiApp { // probe — it only succeeds once all schema queries execute without errors, // handling both network failures and "Cluster not yet healthy" responses. if !self.context.args.no_completion { + // Mark ping_active so drain_bg_output won't spawn a duplicate loop + // when ConnectionStatus(false) arrives from this same task. + self.ping_active = true; self.spawn_schema_retry_loop(true); } From 40b848896bd49f92c6a8c8026b5a516a1a650381 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 10:40:07 +0100 Subject: [PATCH 125/147] Verify schema cache populated before reporting ConnectionStatus(true) After cache.refresh() returns Ok(()), check that functions or tables are non-empty. If both are empty the refresh completed via the is_refreshing() early-exit path without populating the cache, so retry instead of signalling a successful reconnection. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 185e23e..f107eeb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -814,17 +814,20 @@ impl TuiApp { } is_first = false; - match cache.refresh(&mut ctx).await { + let cache_populated = match cache.refresh(&mut ctx).await { Ok(()) => { - let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); - return; - } - Err(_) => { - if !sent_disconnected { - let _ = bg_tx.send(TuiMsg::ConnectionStatus(false)); - sent_disconnected = true; - } + !cache.get_all_functions().is_empty() + || !cache.get_all_tables().is_empty() } + Err(_) => false, + }; + if cache_populated { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); + return; + } + if !sent_disconnected { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(false)); + sent_disconnected = true; } } }); From 376c91f430a5c840222945353a03e9f4631a50d4 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 10:44:21 +0100 Subject: [PATCH 126/147] Add /view and /refresh to command history Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f107eeb..2890d29 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1783,8 +1783,14 @@ impl TuiApp { } match cmd { - "/view" => self.open_viewer(), - "/refresh" | "/refresh_cache" => self.do_refresh(), + "/view" => { + self.history.add(cmd.to_string()); + self.open_viewer(); + } + "/refresh" | "/refresh_cache" => { + self.history.add(cmd.to_string()); + self.do_refresh(); + } _ => { match handle_meta_command(&mut self.context, cmd) { Ok(true) => {} From 84646235624aba9323d707d5aef90651026661cc Mon Sep 17 00:00:00 2001 From: Tobias <97178776+tobias-fire@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:05:52 +0100 Subject: [PATCH 127/147] feat: expose fb_cli_main as C staticlib entry point (#12) - Add src/lib.rs with all module declarations, run(), run_query(), exit_code_for(), and a #[no_mangle] fb_cli_main(argc, argv) C FFI entry point so fb-cli can be embedded in a C++ binary - Add get_args_from(raw: &[String]) to args.rs so the caller can inject an explicit argv (e.g. ["fb", "--core", ...]) instead of reading std::env::args(); get_args() delegates to it - Simplify main.rs to a thin shim that calls fb_cli_main via FFI - Remove devgen-tree-sitter-sql and tree-sitter dependencies (unused); delete src/sql_parser.rs - Change crate-type to ["staticlib", "rlib"] so both the C staticlib and the Rust binary can use the library Co-authored-by: Claude Sonnet 4.6 --- Cargo.lock | 22 ------- Cargo.toml | 6 +- src/args.rs | 17 ++++- src/lib.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 162 ++------------------------------------------- src/sql_parser.rs | 19 ------ 6 files changed, 188 insertions(+), 201 deletions(-) create mode 100644 src/lib.rs delete mode 100644 src/sql_parser.rs diff --git a/Cargo.lock b/Cargo.lock index ba54b92..4b66991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,16 +634,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "devgen-tree-sitter-sql" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19761d763d00dc4dba06f50488b2a72979a36840760faea031480aec88dba559" -dependencies = [ - "cc", - "tree-sitter", -] - [[package]] name = "digest" version = "0.10.7" @@ -757,7 +747,6 @@ version = "0.2.3" dependencies = [ "crossterm", "csvlens", - "devgen-tree-sitter-sql", "dirs", "gumdrop", "nucleo-matcher", @@ -776,7 +765,6 @@ dependencies = [ "tokio", "tokio-util", "toml", - "tree-sitter", "tui-textarea", "urlencoding", ] @@ -2735,16 +2723,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tree-sitter" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705bf7c0958d0171dd7d3a6542f2f4f21d87ed5f1dc8db52919d3a6bed9a359a" -dependencies = [ - "cc", - "regex", -] - [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/Cargo.toml b/Cargo.toml index 911ee6f..480b665 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,10 @@ license = "Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "fb" +crate-type = ["staticlib", "rlib"] + [dependencies] ratatui = "0.29" tui-textarea = "0.7" @@ -27,7 +31,5 @@ pest = "2.7" pest_derive = "2.7" terminal_size = "0.3" csvlens = "0.14" -devgen-tree-sitter-sql = "0.21.0" -tree-sitter = "0.21.0" nucleo-matcher = "0.3.1" sqlformat = "0.5.0" diff --git a/src/args.rs b/src/args.rs index 4553ca3..8afff58 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,4 +1,4 @@ -use gumdrop::Options; +use gumdrop::{Options, ParsingStyle}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; @@ -193,6 +193,12 @@ pub fn normalize_extras(extras: Vec, encode: bool) -> Result // Apply defaults and possibly update them. pub fn get_args() -> Result> { + let raw: Vec = std::env::args().collect(); + get_args_from(&raw) +} + +/// Like `get_args()` but parses from an explicit argv slice (raw[0] is the program name). +pub fn get_args_from(raw: &[String]) -> Result> { let config_path = config_path()?; let defaults: Args = if config_path.exists() { @@ -201,7 +207,14 @@ pub fn get_args() -> Result> { serde_yaml::from_str("")? }; - let mut args = Args::parse_args_default_or_exit(); + let skip = raw.len().min(1); + let mut args = match Args::parse_args(&raw[skip..], ParsingStyle::default()) { + Ok(a) => a, + Err(e) => { + eprintln!("{}", e); + std::process::exit(2); + } + }; args.extra = normalize_extras(args.extra, true)?; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3f928a2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,163 @@ +use std::ffi::CStr; +use std::io::IsTerminal; +use std::os::raw::{c_char, c_int}; +use std::sync::Arc; + +pub mod args; +pub mod auth; +pub mod completion; +pub mod context; +pub mod highlight; +pub mod meta_commands; +pub mod query; +pub mod table_renderer; +pub mod tui; +pub mod tui_msg; +pub mod utils; +pub mod viewer; + +use args::get_args_from; +use auth::maybe_authenticate; +use completion::schema_cache::SchemaCache; +use completion::usage_tracker::UsageTracker; +use context::Context; +use query::{ErrorKind, QueryFailed, dot_command, query}; +use tui::TuiApp; + +pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); +pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.4"; + +/// Run the CLI with the given argv (raw[0] is the program name). +/// Returns the process exit code. +pub async fn run(raw_args: Vec) -> i32 { + let args = match get_args_from(&raw_args) { + Ok(a) => a, + Err(e) => { eprintln!("Error: {}", e); return ErrorKind::SystemError as i32; } + }; + + if args.version { + println!("fb-cli version {}", CLI_VERSION); + return 0; + } + + let mut context = Context::new(args); + if let Err(e) = maybe_authenticate(&mut context).await { + eprintln!("Error: {}", e); + return ErrorKind::SystemError as i32; + } + + let query_text = if context.args.command.is_empty() { + context.args.query.join(" ") + } else { + format!("{} {}", context.args.command, context.args.query.join(" ")) + }; + + // ── Headless mode: query provided on the command line ──────────────────── + if !query_text.is_empty() { + return match query(&mut context, query_text).await { + Ok(()) => 0, + Err(e) => exit_code_for(&e), + }; + } + + let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); + context.is_interactive = is_tty; + + // ── Non-interactive (pipe/redirect): fall through to headless behaviour ── + if !is_tty { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut buffer = String::new(); + let mut worst: i32 = 0; + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(e) => { eprintln!("Error reading stdin: {}", e); return ErrorKind::SystemError as i32; } + }; + buffer.push_str(&line); + + if buffer.trim() == "quit" || buffer.trim() == "exit" { + buffer.clear(); + break; + } + + buffer.push('\n'); + + let queries = query::try_split_queries(&buffer).unwrap_or_default(); + if !queries.is_empty() { + for q in queries { + let dotcheck = q.trim().trim_end_matches(';').trim(); + if dot_command(&mut context, dotcheck) { + // client-side setting applied; nothing to send to server + } else { + worst = worst.max(run_query(&mut context, q).await); + } + } + buffer.clear(); + } + } + + // Try remaining buffer with implicit semicolon (EOF with incomplete query) + if !buffer.trim().is_empty() { + let text = format!("{};", buffer.trim()); + if let Some(queries) = query::try_split_queries(&text) { + for q in queries { + let dotcheck = q.trim().trim_end_matches(';').trim(); + if dot_command(&mut context, dotcheck) { + // client-side setting applied + } else { + worst = worst.max(run_query(&mut context, q).await); + } + } + } + } + + return worst; + } + + // ── Interactive TUI mode ───────────────────────────────────────────────── + let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); + let usage_tracker = Arc::new(UsageTracker::new(10)); + context.usage_tracker = Some(usage_tracker); + + let app = TuiApp::new(context, schema_cache); + match app.run().await { + Ok(had_error) => if had_error { 1 } else { 0 }, + Err(e) => { eprintln!("Error: {}", e); ErrorKind::SystemError as i32 } + } +} + +/// Run a single query and return its exit code (0, 1, or 2). +pub async fn run_query(context: &mut Context, q: String) -> i32 { + match query(context, q).await { + Ok(()) => 0, + Err(e) => exit_code_for(&e), + } +} + +/// Map a query error to an exit code. +pub fn exit_code_for(e: &Box) -> i32 { + if let Some(qf) = e.downcast_ref::() { + qf.0 as i32 + } else { + eprintln!("Error: {}", e); + ErrorKind::SystemError as i32 + } +} + +/// C-callable entry point. `argc`/`argv` mirror the process argv so the caller +/// can inject CLI flags (e.g. `["fb", "--core"]`). +#[no_mangle] +pub extern "C" fn fb_cli_main(argc: c_int, argv: *const *const c_char) -> c_int { + let args: Vec = (0..argc as usize) + .map(|i| unsafe { CStr::from_ptr(*argv.add(i)).to_string_lossy().into_owned() }) + .collect(); + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime") + .block_on(run(args)) as c_int +} diff --git a/src/main.rs b/src/main.rs index 5f093f7..543bac6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,157 +1,7 @@ -use std::io::IsTerminal; -use std::sync::Arc; - -mod args; -mod auth; -mod completion; -mod context; -mod highlight; -mod meta_commands; -mod query; -mod sql_parser; -mod table_renderer; -mod tui; -mod tui_msg; -mod utils; -mod viewer; - -use args::get_args; -use auth::maybe_authenticate; -use completion::schema_cache::SchemaCache; -use completion::usage_tracker::UsageTracker; -use context::Context; -use query::{ErrorKind, QueryFailed, dot_command, query}; -use tui::TuiApp; - -pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); -pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.4"; - -#[tokio::main] -async fn main() { - std::process::exit(run().await); -} - -/// Returns the process exit code: -/// 0 — all queries succeeded -/// 1 — one or more queries failed (bad SQL, permission denied, HTTP 400) -/// 2 — system/infrastructure error (connection refused, auth failure, HTTP 4xx/5xx other than 400) -async fn run() -> i32 { - let args = match get_args() { - Ok(a) => a, - Err(e) => { eprintln!("Error: {}", e); return ErrorKind::SystemError as i32; } - }; - - if args.version { - println!("fb-cli version {}", CLI_VERSION); - return 0; - } - - let mut context = Context::new(args); - if let Err(e) = maybe_authenticate(&mut context).await { - eprintln!("Error: {}", e); - return ErrorKind::SystemError as i32; - } - - let query_text = if context.args.command.is_empty() { - context.args.query.join(" ") - } else { - format!("{} {}", context.args.command, context.args.query.join(" ")) - }; - - // ── Headless mode: query provided on the command line ──────────────────── - if !query_text.is_empty() { - return match query(&mut context, query_text).await { - Ok(()) => 0, - Err(e) => exit_code_for(&e), - }; - } - - let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); - context.is_interactive = is_tty; - - // ── Non-interactive (pipe/redirect): fall through to headless behaviour ── - if !is_tty { - use std::io::BufRead; - let stdin = std::io::stdin(); - let mut buffer = String::new(); - let mut worst: i32 = 0; - - for line in stdin.lock().lines() { - let line = match line { - Ok(l) => l, - Err(e) => { eprintln!("Error reading stdin: {}", e); return ErrorKind::SystemError as i32; } - }; - buffer.push_str(&line); - - // Handle quit/exit before appending a newline (mirrors old REPL behaviour) - if buffer.trim() == "quit" || buffer.trim() == "exit" { - buffer.clear(); - break; - } - - buffer.push('\n'); - - let queries = query::try_split_queries(&buffer).unwrap_or_default(); - if !queries.is_empty() { - for q in queries { - let dotcheck = q.trim().trim_end_matches(';').trim(); - if dot_command(&mut context, dotcheck) { - // client-side setting applied; nothing to send to server - } else { - worst = worst.max(run_query(&mut context, q).await); - } - } - buffer.clear(); - } - } - - // Try remaining buffer with implicit semicolon (EOF with incomplete query) - if !buffer.trim().is_empty() { - let text = format!("{};", buffer.trim()); - if let Some(queries) = query::try_split_queries(&text) { - for q in queries { - let dotcheck = q.trim().trim_end_matches(';').trim(); - if dot_command(&mut context, dotcheck) { - // client-side setting applied - } else { - worst = worst.max(run_query(&mut context, q).await); - } - } - } - } - - return worst; - } - - // ── Interactive TUI mode ───────────────────────────────────────────────── - let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); - let usage_tracker = Arc::new(UsageTracker::new(10)); - context.usage_tracker = Some(usage_tracker); - - let app = TuiApp::new(context, schema_cache); - match app.run().await { - Ok(had_error) => if had_error { 1 } else { 0 }, - Err(e) => { eprintln!("Error: {}", e); ErrorKind::SystemError as i32 } - } -} - -/// Run a single query and return its exit code (0, 1, or 2). -async fn run_query(context: &mut Context, q: String) -> i32 { - match query(context, q).await { - Ok(()) => 0, - Err(e) => exit_code_for(&e), - } -} - -/// Map a query error to an exit code. -/// `QueryFailed` errors have their message already printed; others are printed here. -fn exit_code_for(e: &Box) -> i32 { - if let Some(qf) = e.downcast_ref::() { - qf.0 as i32 - } else { - // Error propagated via `?` (auth failure, network setup, etc.) — print it now. - eprintln!("Error: {}", e); - ErrorKind::SystemError as i32 - } +fn main() { + let args: Vec = std::env::args() + .map(|a| std::ffi::CString::new(a).unwrap()) + .collect(); + let ptrs: Vec<*const std::os::raw::c_char> = args.iter().map(|a| a.as_ptr()).collect(); + std::process::exit(fb::fb_cli_main(ptrs.len() as _, ptrs.as_ptr())); } diff --git a/src/sql_parser.rs b/src/sql_parser.rs deleted file mode 100644 index e192bc9..0000000 --- a/src/sql_parser.rs +++ /dev/null @@ -1,19 +0,0 @@ -/// Shared tree-sitter SQL parser infrastructure. -/// -/// Used by the completion system (Phase 6) for AST-based context detection. -/// Each component that needs parsing should call `create_parser()` to get its -/// own `Parser` instance — `tree_sitter::Parser` is not `Sync`. - -#[allow(dead_code)] -pub fn sql_language() -> tree_sitter::Language { - devgen_tree_sitter_sql::language() -} - -#[allow(dead_code)] -pub fn create_parser() -> tree_sitter::Parser { - let mut parser = tree_sitter::Parser::new(); - parser - .set_language(&sql_language()) - .expect("tree-sitter SQL grammar should always load"); - parser -} From 58b27cabcc1f5a83325e89c93a6ec75b4d1445ff Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 14:40:31 +0100 Subject: [PATCH 128/147] feat: add Unix domain socket transport for server communication Add transport.rs module abstracting HTTP over both TCP (reqwest) and Unix domain sockets (hyper). The --unix-socket flag routes all queries through the given socket path, removing the need for TCP on port 3473. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 4 + Cargo.toml | 4 + src/args.rs | 4 + src/lib.rs | 1 + src/query.rs | 97 +++++++----------------- src/transport.rs | 191 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 4b66991..7221ead 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,10 +745,14 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" name = "fb" version = "0.2.3" dependencies = [ + "bytes", "crossterm", "csvlens", "dirs", "gumdrop", + "http-body-util", + "hyper", + "hyper-util", "nucleo-matcher", "once_cell", "openssl", diff --git a/Cargo.toml b/Cargo.toml index 480b665..799fabc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,10 @@ tui-textarea = "0.7" crossterm = "0.28" gumdrop = { version = "0.8.1", features = ["default_expr"] } reqwest = { version = "0.12", features = ["json", "http2"] } +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" openssl = { version = "*", features = ["vendored"] } tokio = { version = "1", features = ["full"] } tokio-util = "0.7.10" diff --git a/src/args.rs b/src/args.rs index 8afff58..b8042a3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -125,6 +125,10 @@ pub struct Args { #[serde(default)] pub version: bool, + #[options(no_short, help = "Connect to server via Unix domain socket at this path")] + #[serde(skip_serializing, skip_deserializing)] + pub unix_socket: String, + #[options(no_short, help = "Show help message and exit")] #[serde(skip_serializing, skip_deserializing)] pub help: bool, diff --git a/src/lib.rs b/src/lib.rs index 3f928a2..2a37dd8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ use std::sync::Arc; pub mod args; pub mod auth; +pub mod transport; pub mod completion; pub mod context; pub mod highlight; diff --git a/src/query.rs b/src/query.rs index 4b2b620..062edb4 100644 --- a/src/query.rs +++ b/src/query.rs @@ -66,9 +66,8 @@ use crate::args::normalize_extras; use crate::auth::authenticate_service_account; use crate::context::Context; use crate::table_renderer; +use crate::transport; use crate::utils::spin; -use crate::FIREBOLT_PROTOCOL_VERSION; -use crate::USER_AGENT; // ── Output helpers ──────────────────────────────────────────────────────────── // These macros route output through the TUI channel when running in TUI mode, @@ -287,59 +286,33 @@ fn remove_parameters(context: &mut Context, keys: &str) { // Execute a query silently and return the response body (for internal use like schema queries) pub async fn query_silent(context: &mut Context, query_text: &str) -> Result> { - let request = reqwest::Client::builder() - .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) - .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) - .http2_keep_alive_while_idle(false) - .tcp_keepalive(Some(std::time::Duration::from_secs(60))) - .build()? - .post(context.url.clone()) - .header("user-agent", USER_AGENT) - .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) - .header("Firebolt-Machine-Query", "true") - .body(query_text.to_string()); - - let request = if let Some(sa_token) = &context.sa_token { - request.header("authorization", format!("Bearer {}", sa_token.token)) + let auth = if let Some(sa_token) = &context.sa_token { + Some(format!("Bearer {}", sa_token.token)) } else if !context.args.jwt.is_empty() { - request.header("authorization", format!("Bearer {}", context.args.jwt)) + Some(format!("Bearer {}", context.args.jwt)) } else { - request + None }; - - let response = request.send().await?; - let body = response.text().await?; - Ok(body) + let unix_socket = (!context.args.unix_socket.is_empty()).then_some(context.args.unix_socket.as_str()); + let response = transport::post(&context.url, unix_socket, query_text.to_string(), auth, true).await?; + Ok(response.text().await?) } /// Send `SELECT 1;` with the current context URL to validate that all query /// parameters are accepted by the server. Returns an error message on HTTP /// 4xx/5xx or connection failure. pub async fn validate_setting(context: &mut Context) -> Result<(), String> { - let client = reqwest::Client::builder() - .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) - .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) - .http2_keep_alive_while_idle(false) - .tcp_keepalive(Some(std::time::Duration::from_secs(60))) - .build() - .map_err(|e| e.to_string())?; - - let req = client - .post(context.url.clone()) - .header("user-agent", USER_AGENT) - .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) - .header("Firebolt-Machine-Query", "true") - .body("SELECT 1;"); - - let req = if let Some(sa_token) = &context.sa_token { - req.header("authorization", format!("Bearer {}", sa_token.token)) + let auth = if let Some(sa_token) = &context.sa_token { + Some(format!("Bearer {}", sa_token.token)) } else if !context.args.jwt.is_empty() { - req.header("authorization", format!("Bearer {}", context.args.jwt)) + Some(format!("Bearer {}", context.args.jwt)) } else { - req + None }; - - let response = req.send().await.map_err(|e| e.to_string())?; + let unix_socket = (!context.args.unix_socket.is_empty()).then_some(context.args.unix_socket.as_str()); + let response = transport::post(&context.url, unix_socket, "SELECT 1;".to_string(), auth, true) + .await + .map_err(|e| e.to_string())?; if !response.status().is_success() { let body = response.text().await.unwrap_or_default(); return Err(readable_error(&body)); @@ -443,26 +416,16 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< // Clone query_text for tracking later let query_text_for_tracking = query_text.clone(); - let mut request = reqwest::Client::builder() - .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) - .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) - .http2_keep_alive_while_idle(false) - .tcp_keepalive(Some(std::time::Duration::from_secs(60))) - .build()? - .post(context.url.clone()) - .header("user-agent", USER_AGENT) - .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) - .body(query_text); - - if let Some(sa_token) = &context.sa_token { - request = request.header("authorization", format!("Bearer {}", sa_token.token)); - } - - if !context.args.jwt.is_empty() { - request = request.header("authorization", format!("Bearer {}", context.args.jwt)); - } - - let async_resp = request.send(); + let auth = if let Some(sa_token) = &context.sa_token { + Some(format!("Bearer {}", sa_token.token)) + } else if !context.args.jwt.is_empty() { + Some(format!("Bearer {}", context.args.jwt)) + } else { + None + }; + let unix_socket = (!context.args.unix_socket.is_empty()).then_some(context.args.unix_socket.as_str()); + let url = context.url.clone(); + let async_resp = transport::post(&url, unix_socket, query_text, auth, false); // Build a cancel future: prefer the TUI's CancellationToken, fall back to SIGINT. let cancel = context.query_cancel.clone(); @@ -705,7 +668,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } else { // ── Buffered path (non-interactive or server-rendered) ── - let body = resp.text().await?; + let body = resp.text().await.map_err(|e| -> Box { e })?; if context.args.should_render_table() { match table_renderer::parse_jsonlines_compact(&body) { @@ -753,11 +716,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } } Err(error) => { - if context.args.verbose { - out_err!(context, "Failed to send the request: {:?}", error); - } else { - out_err!(context, "Failed to send the request: {}", error.to_string()); - } + out_err!(context, "Failed to send the request: {}", error); error_kind = Some(ErrorKind::SystemError); }, }; diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..4613fd0 --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,191 @@ +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::client::conn::http1; +use hyper::header::HeaderMap; +use hyper::StatusCode; +use hyper_util::rt::TokioIo; +use tokio::net::UnixStream; + +use crate::{FIREBOLT_PROTOCOL_VERSION, USER_AGENT}; + +/// HTTP/1.1 response received over a Unix domain socket. +pub struct UnixResponse { + status: StatusCode, + headers: HeaderMap, + body: Incoming, +} + +impl UnixResponse { + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// Read the next chunk from the response body, skipping empty data frames and trailers. + pub async fn chunk( + &mut self, + ) -> Result, Box> { + loop { + match self.body.frame().await { + None => return Ok(None), + Some(Err(e)) => return Err(Box::new(e)), + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + if !data.is_empty() { + return Ok(Some(data)); + } + // empty data frame — keep reading + } + // trailer frame — keep reading + } + } + } + } + + pub async fn text(mut self) -> Result> { + let mut s = String::new(); + while let Some(chunk) = self.chunk().await? { + s.push_str(std::str::from_utf8(&chunk)?); + } + Ok(s) + } +} + +/// Unified response from either a TCP (reqwest) or Unix-socket (hyper) HTTP request. +pub enum Response { + Http(reqwest::Response), + Unix(UnixResponse), +} + +impl Response { + pub fn status(&self) -> StatusCode { + match self { + Self::Http(r) => r.status(), + Self::Unix(r) => r.status(), + } + } + + pub fn headers(&self) -> &HeaderMap { + match self { + Self::Http(r) => r.headers(), + Self::Unix(r) => r.headers(), + } + } + + /// Stream the next chunk from the response body. + pub async fn chunk( + &mut self, + ) -> Result, Box> { + match self { + Self::Http(r) => Ok(r.chunk().await?), + Self::Unix(r) => r.chunk().await, + } + } + + /// Collect the entire body as a UTF-8 string. + pub async fn text(self) -> Result> { + match self { + Self::Http(r) => Ok(r.text().await?), + Self::Unix(r) => r.text().await, + } + } +} + +/// Send a POST request, routing through a Unix domain socket when `unix_socket` is set, +/// or falling back to standard TCP via reqwest. +pub async fn post( + url: &str, + unix_socket: Option<&str>, + body: String, + authorization: Option, + machine_query: bool, +) -> Result> { + match unix_socket.filter(|s| !s.is_empty()) { + Some(path) => unix_post(path, url, body, authorization, machine_query) + .await + .map(Response::Unix), + None => tcp_post(url, body, authorization, machine_query) + .await + .map(Response::Http), + } +} + +async fn tcp_post( + url: &str, + body: String, + authorization: Option, + machine_query: bool, +) -> Result> { + let mut request = reqwest::Client::builder() + .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) + .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) + .http2_keep_alive_while_idle(false) + .tcp_keepalive(Some(std::time::Duration::from_secs(60))) + .build()? + .post(url) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION); + + if machine_query { + request = request.header("Firebolt-Machine-Query", "true"); + } + if let Some(auth) = authorization { + request = request.header("authorization", auth); + } + + Ok(request.body(body).send().await?) +} + +async fn unix_post( + socket_path: &str, + url: &str, + body: String, + authorization: Option, + machine_query: bool, +) -> Result> { + // Extract path+query from the URL; the unix socket ignores host and port. + let parsed = reqwest::Url::parse(url)?; + let path_and_query = match parsed.query() { + Some(q) => format!("{}?{}", parsed.path(), q), + None => parsed.path().to_string(), + }; + let host = parsed.host_str().unwrap_or("localhost"); + + // Connect to the Unix domain socket. + let stream = UnixStream::connect(socket_path).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = http1::handshake(io).await?; + tokio::spawn(async move { + let _ = conn.await; + }); + + // Build and send the HTTP/1.1 POST request. + let mut builder = hyper::Request::builder() + .method("POST") + .uri(path_and_query) + .header("host", host) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) + .header("content-length", body.len().to_string()); + + if machine_query { + builder = builder.header("Firebolt-Machine-Query", "true"); + } + if let Some(auth) = authorization { + builder = builder.header("authorization", auth); + } + + let request = builder.body(Full::new(Bytes::from(body)))?; + let response = sender.send_request(request).await?; + let (parts, body) = response.into_parts(); + + Ok(UnixResponse { + status: parts.status, + headers: parts.headers, + body, + }) +} From 55d268e7ee57bc196bfd20c317c252950790586c Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 14:49:50 +0100 Subject: [PATCH 129/147] fix: show unix socket path instead of host in TUI status bar When --unix-socket is set, the status bar was still displaying the HTTP host (e.g. localhost:3473). Now it shows the socket path instead. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2890d29..920509a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2957,7 +2957,11 @@ impl TuiApp { } fn render_status_bar(&mut self, f: &mut ratatui::Frame, area: Rect) { - let host = &self.context.args.host; + let host = if !self.context.args.unix_socket.is_empty() { + &self.context.args.unix_socket + } else { + &self.context.args.host + }; let db = &self.context.args.database; // Show cursor position (1-based) when the textarea is visible. From cd42c5c93c816c1acfcbe8537f4a65f66b6c7217 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Wed, 25 Feb 2026 11:13:42 +0100 Subject: [PATCH 130/147] Fix misaligned syntax highlighting after Alt+F format set_textarea_content creates a fresh TextArea whose viewport starts at (0,0), but ta_col_top/ta_row_top retained the old scroll offset. next_scroll_top never resets, so a stale col_top caused apply_textarea_highlights to map characters to wrong screen columns. Reset both mirrors to 0 whenever the textarea content is replaced. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 920509a..f682760 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2330,6 +2330,11 @@ impl TuiApp { // Move cursor to end of content self.textarea.move_cursor(CursorMove::Bottom); self.textarea.move_cursor(CursorMove::End); + // The new TextArea starts with viewport at (0,0). Reset our mirror so + // apply_textarea_highlights doesn't use stale offsets from before the + // content was replaced (stale col_top causes misaligned highlighting). + self.ta_row_top = 0; + self.ta_col_top = 0; } // ── Rendering ──────────────────────────────────────────────────────────── From 3e9a90bfece67d7c67492ebf2d418a9fc3d5fa31 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 2 Mar 2026 18:23:41 +0100 Subject: [PATCH 131/147] Add --param / -p flag for parameterized queries in non-interactive mode Passes positional parameters as Firebolt's query_parameters URL parameter. First -p value becomes $1, second becomes $2, etc. Values are auto-typed: integers, floats, booleans, and NULL are sent as their native JSON types; everything else is sent as a JSON string. Also fix pre-existing test_params_escaping failure: alias param() columns so the column name is stable across server versions. Co-Authored-By: Claude Sonnet 4.6 --- src/args.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/cli.rs | 31 ++++++++++++++-- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/args.rs b/src/args.rs index b8042a3..929ee2f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -133,6 +133,14 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub help: bool, + #[options( + short = "p", + help = "Query parameter value; positional: first -p is $1, second is $2, etc. \ + Values are auto-typed: integers, floats, booleans, NULL, or strings." + )] + #[serde(skip_serializing, skip_deserializing)] + pub params: Vec, + #[options(free, help = "Query command(s) to execute. If not specified, starts the REPL")] #[serde(skip_serializing, skip_deserializing)] pub query: Vec, @@ -292,6 +300,35 @@ pub fn get_args_from(raw: &[String]) -> Result> Ok(args) } +/// Infer the JSON type of a query parameter value. +/// +/// Rules (tried in order): +/// - `null` / `NULL` → JSON null +/// - `true` / `false` → JSON boolean +/// - Parses as i64 → JSON integer +/// - Parses as f64 → JSON float +/// - Otherwise → JSON string +fn param_to_json_value(s: &str) -> serde_json::Value { + if s.eq_ignore_ascii_case("null") { + return serde_json::Value::Null; + } + if s.eq_ignore_ascii_case("true") { + return serde_json::Value::Bool(true); + } + if s.eq_ignore_ascii_case("false") { + return serde_json::Value::Bool(false); + } + if let Ok(i) = s.parse::() { + return serde_json::Value::Number(i.into()); + } + if let Ok(f) = s.parse::() { + if let Some(n) = serde_json::Number::from_f64(f) { + return serde_json::Value::Number(n); + } + } + serde_json::Value::String(s.to_string()) +} + // Create URL from Args pub fn get_url(args: &Args) -> String { let query_label = if !args.label.is_empty() && !args.extra.iter().any(|e| e.starts_with("query_label=")) { @@ -327,8 +364,21 @@ pub fn get_url(args: &Args) -> String { }; let advanced_mode = if is_localhost { "" } else { "&advanced_mode=1" }; + let query_parameters = if args.params.is_empty() { + String::new() + } else { + let arr: Vec = args.params.iter().enumerate() + .map(|(i, v)| serde_json::json!({ + "name": format!("${}", i + 1), + "value": param_to_json_value(v), + })) + .collect(); + let json = serde_json::to_string(&arr).unwrap_or_default(); + format!("&query_parameters={}", urlencoding::encode(&json)) + }; + format!( - "{protocol}://{host}/?{database}{query_label}{extra}{output_format}{advanced_mode}", + "{protocol}://{host}/?{database}{query_label}{extra}{output_format}{advanced_mode}{query_parameters}", host = args.host ) } @@ -496,6 +546,54 @@ mod tests { assert!(!args.is_horizontal_display()); } + #[test] + fn test_param_to_json_value() { + assert_eq!(param_to_json_value("null"), serde_json::Value::Null); + assert_eq!(param_to_json_value("NULL"), serde_json::Value::Null); + assert_eq!(param_to_json_value("true"), serde_json::Value::Bool(true)); + assert_eq!(param_to_json_value("false"), serde_json::Value::Bool(false)); + assert_eq!(param_to_json_value("42"), serde_json::json!(42i64)); + assert_eq!(param_to_json_value("-7"), serde_json::json!(-7i64)); + assert_eq!(param_to_json_value("3.14"), serde_json::json!(3.14f64)); + assert_eq!(param_to_json_value("hello"), serde_json::json!("hello")); + assert_eq!(param_to_json_value("42abc"), serde_json::json!("42abc")); + } + + #[test] + fn test_get_url_with_params() { + let mut args = Args::parse_args_default_or_exit(); + args.host = "localhost:8123".to_string(); + args.database = "test_db".to_string(); + args.format = "PSQL".to_string(); + args.params = vec!["42".to_string(), "alice".to_string(), "true".to_string()]; + + let url = get_url(&args); + // Should contain URL-encoded JSON array + assert!(url.contains("query_parameters="), "URL should contain query_parameters"); + // Decode and verify content + let encoded = url.split("query_parameters=").nth(1).unwrap().split('&').next().unwrap(); + let decoded = urlencoding::decode(encoded).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&decoded).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 3); + assert_eq!(arr[0]["name"], "$1"); + assert_eq!(arr[0]["value"], 42i64); + assert_eq!(arr[1]["name"], "$2"); + assert_eq!(arr[1]["value"], "alice"); + assert_eq!(arr[2]["name"], "$3"); + assert_eq!(arr[2]["value"], true); + } + + #[test] + fn test_get_url_without_params() { + let mut args = Args::parse_args_default_or_exit(); + args.host = "localhost:8123".to_string(); + args.format = "PSQL".to_string(); + + let url = get_url(&args); + assert!(!url.contains("query_parameters=")); + } + #[test] fn test_format_without_client_prefix() { // Test that formats "auto", "vertical", "horizontal" without "client:" prefix diff --git a/tests/cli.rs b/tests/cli.rs index 11c90a9..55d529e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -89,11 +89,11 @@ fn test_params_escaping() { .unwrap(); let mut stdin = child.stdin.take().unwrap(); - writeln!(stdin, "SELECT param('$1');").unwrap(); + writeln!(stdin, "SELECT param('$1') AS param;").unwrap(); writeln!(stdin, "SET advanced_mode=true;").unwrap(); - writeln!(stdin, "SELECT param('$1');").unwrap(); + writeln!(stdin, "SELECT param('$1') AS param;").unwrap(); writeln!(stdin, r#"SET query_parameters={{"name": "$1", "value": "b=}}&"}};"#).unwrap(); - writeln!(stdin, "SELECT param('$1');").unwrap(); + writeln!(stdin, "SELECT param('$1') AS param;").unwrap(); drop(stdin); // Close stdin to end interactive mode let output = child.wait_with_output().unwrap(); @@ -115,6 +115,31 @@ fn test_params_escaping() { assert_eq!(lines.next().unwrap(), "b=}&"); } +#[test] +fn test_param_flag() { + // Integer, string, boolean and NULL params; verify they substitute correctly. + let (success, stdout, _) = run_fb(&[ + "--core", + "-f", "TabSeparatedWithNamesAndTypes", + "-p", "42", + "-p", "hello", + "-p", "true", + "-p", "NULL", + "SELECT param('$1') AS p1, param('$2') AS p2, param('$3') AS p3, param('$4') AS p4;", + ]); + assert!(success, "query with -p params should succeed"); + let mut lines = stdout.lines(); + assert_eq!(lines.next().unwrap(), "p1\tp2\tp3\tp4"); + // TabSeparatedWithNamesAndTypes emits type names on the second line + let _types = lines.next().unwrap(); + let values = lines.next().unwrap(); + let cols: Vec<&str> = values.split('\t').collect(); + assert_eq!(cols[0], "42"); + assert_eq!(cols[1], "hello"); + assert_eq!(cols[2], "true"); + assert_eq!(cols[3], ""); // NULL in Firebolt TSV format is empty string +} + #[test] fn test_argument_parsing_space_separated() { // Test space-separated argument format: --host localhost --database testdb From 73040a2b8eeaf63434562816d1488331a0a91eec Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Mon, 2 Mar 2026 18:27:01 +0100 Subject: [PATCH 132/147] Document --param / -p flag in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 1f438df..cb0736d 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,40 @@ Saved defaults are stored in `~/.firebolt/fb_config` and merged with any flags y | `1` | One or more queries failed (bad SQL, permission denied, HTTP 400) | | `2` | System/infrastructure error (connection refused, auth failure, HTTP 4xx/5xx) | +## Parameterized Queries + +Use `-p` / `--param` to pass positional parameters in non-interactive mode. The first `-p` value becomes `$1`, the second `$2`, and so on. + +```bash +fb --core -p 42 -p 'Alice' \ + "SELECT * FROM users WHERE id = \$1 AND name = \$2" +``` + +Parameters are automatically typed from the value: + +| Value passed | JSON sent | Example | +|---|---|---| +| Integer | number | `-p 42` → `42` | +| Float | number | `-p 3.14` → `3.14` | +| `true` / `false` | boolean | `-p true` → `true` | +| `NULL` | null | `-p NULL` → `null` | +| Anything else | string | `-p hello` → `"hello"` | + +Works in all non-interactive modes: + +```bash +# Single-query +fb --core -p 10 -p 20 "SELECT \$1 + \$2" + +# Pipe mode +echo "SELECT \$1, \$2;" | fb --core -p hello -p world + +# With a file +/run @query.sql # in REPL (use set/unset for runtime params instead) +``` + +Firebolt validates and escapes parameter values server-side, so they are safe against SQL injection. + ## Scripting ### stdout vs stderr @@ -398,6 +432,8 @@ Optional arguments: client:horizontal, PSQL, JSON_Compact, JSONLines_Compact, TabSeparatedWithNamesAndTypes, ...) -e, --extra NAME=VALUE Extra query parameters (repeatable) + -p, --params VALUE Query parameter: first -p is $1, second is $2, ... + Values auto-typed: int, float, bool, NULL, or string -l, --label LABEL Query label for tracking -j, --jwt JWT JWT token for authentication --sa-id SA-ID Service Account ID From db598eda64f6a403b57ec0922123ebcc0522ab3b Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 3 Mar 2026 09:54:50 +0100 Subject: [PATCH 133/147] Fix multi-line string rendering in client-side table formats Previously, fmt_cell replaced all control characters (including \n) with spaces, collapsing multi-line values into a single line and losing structure. Changes: - fmt_cell: preserve \n; only replace other control chars with spaces - wrap_cell: split on \n first (hard line breaks), then wrap each segment by column width independently - decide_col_widths: use cell_display_width() (max line width) instead of chars().count() for natural/min width calculation, and rows_needed_for_cell() for the too-tall check, so multi-line cells don't inflate estimates - any_wrap / any_wrap_in_row: also trigger on embedded newlines Co-Authored-By: Claude Sonnet 4.6 --- src/table_renderer.rs | 81 +++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 0c47d0f..d910529 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -189,26 +189,56 @@ pub fn write_result_as_csv( /// Wrap a string (by char boundaries) into lines of at most `width` chars each, /// padding every line to exactly `width` chars with spaces. +/// +/// Embedded `\n` characters are treated as hard line breaks: the string is split +/// on them first, then each segment is independently wrapped at `width` chars. fn wrap_cell(s: &str, width: usize) -> Vec { if width == 0 { return vec![String::new()]; } - let chars: Vec = s.chars().collect(); - if chars.is_empty() { - return vec![" ".repeat(width)]; - } let mut lines = Vec::new(); - let mut start = 0; - while start < chars.len() { - let end = (start + width).min(chars.len()); - let slice: String = chars[start..end].iter().collect(); - let padded = format!("{: = segment.chars().collect(); + if chars.is_empty() { + lines.push(" ".repeat(width)); + continue; + } + let mut start = 0; + while start < chars.len() { + let end = (start + width).min(chars.len()); + let slice: String = chars[start..end].iter().collect(); + let padded = format!("{: usize { + s.split('\n').map(|l| l.chars().count()).max().unwrap_or(0) +} + +/// How many terminal rows a cell needs at column width `w`, accounting for +/// embedded newlines as hard breaks and then word-wrap within each segment. +fn rows_needed_for_cell(s: &str, w: usize) -> usize { + if w == 0 { + return 1; + } + s.split('\n') + .map(|seg| { + let n = seg.chars().count(); + if n == 0 { 1 } else { (n + w - 1) / w } + }) + .sum::() + .max(1) +} + /// Build a TUI border line using box-drawing characters. /// Each column occupies `col_width + 2` chars (1 space padding on each side). fn make_tui_border( @@ -270,7 +300,9 @@ fn decide_col_widths( let natural_widths: Vec = (0..n) .map(|i| { let h = columns[i].name.chars().count(); - let d = formatted.iter().map(|r| r[i].chars().count()).max().unwrap_or(0); + // Use max line width (not total char count) so multi-line cells + // don't inflate the column width estimate. + let d = formatted.iter().map(|r| cell_display_width(&r[i])).max().unwrap_or(0); h.max(d).min(cap) }) .collect(); @@ -294,7 +326,7 @@ fn decide_col_widths( let min_widths: Vec = (0..n) .map(|i| { let header_len = columns[i].name.chars().count(); - let max_content = formatted.iter().map(|r| r[i].chars().count()).max().unwrap_or(0); + let max_content = formatted.iter().map(|r| cell_display_width(&r[i])).max().unwrap_or(0); let min_from_header = (header_len + 1) / 2; // ceil(header_len / 2) let min_from_content = (max_content as f64).sqrt().ceil() as usize; 10usize.max(min_from_header).max(min_from_content) @@ -338,10 +370,8 @@ fn decide_col_widths( // A tall narrow cell is a sign that vertical layout will be much more readable. let any_cell_too_tall = formatted.iter().any(|row| { (0..n).any(|i| { - let len = row[i].chars().count(); let w = col_widths[i].max(1); - let rows_needed = (len + w - 1) / w; - rows_needed > n + rows_needed_for_cell(&row[i], w) > n }) }); if any_cell_too_tall { @@ -367,7 +397,13 @@ fn render_horizontal_tui( // Check if any cell (header or data) needs wrapping. let any_wrap = { let header_wraps = (0..n).any(|i| columns[i].name.chars().count() > col_widths[i]); - let data_wraps = formatted.iter().any(|row| (0..n).any(|i| row[i].chars().count() > col_widths[i])); + let data_wraps = formatted.iter().any(|row| { + (0..n).any(|i| { + // Wraps if the widest line exceeds the column, or if there are + // embedded newlines that force additional rows. + cell_display_width(&row[i]) > col_widths[i] || row[i].contains('\n') + }) + }); header_wraps || data_wraps }; @@ -452,12 +488,13 @@ fn truncate_to_chars(s: String, max_len: usize) -> String { } /// Format and truncate a cell value, using char-count for length measurement. -/// Control characters (newlines, tabs, carriage returns) are replaced with spaces -/// so that wrap_cell produces clean fixed-width chunks that ratatui renders correctly. +/// +/// `\n` is preserved so `wrap_cell` can render it as an actual line break. +/// Other control characters (tabs, carriage returns, etc.) are replaced with +/// spaces to avoid corrupting ratatui's line rendering. fn fmt_cell(val: &Value, max_value_length: usize) -> String { let s = format_value(val); - // Replace control characters that would break ratatui's line rendering. - let s = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect::(); + let s = s.chars().map(|c| if c.is_control() && c != '\n' { ' ' } else { c }).collect::(); truncate_to_chars(s, max_value_length) } @@ -523,7 +560,7 @@ pub fn render_vertical_table_to_tui_lines( let any_wrap_in_row = columns.iter().enumerate().any(|(col_idx, col)| { let name_wraps = col.name.chars().count() > name_col_w; let val = fmt_cell(row.get(col_idx).unwrap_or(&Value::Null), max_value_length); - let val_wraps = val.chars().count() > val_col_w; + let val_wraps = cell_display_width(&val) > val_col_w || val.contains('\n'); name_wraps || val_wraps }); From c5f785e8889f8b5f32b6725ff43f311c71b20e5f Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 3 Mar 2026 10:25:34 +0100 Subject: [PATCH 134/147] Fix slow paste: bracketed paste + drain all events before render Two independent fixes: 1. Enable bracketed paste mode (EnableBracketedPaste / DisableBracketedPaste). The terminal now bundles the entire pasted string into a single Event::Paste(String) instead of sending one Event::Key per character. handle_paste() inserts the whole string before the next render. Bracketed paste is also disabled/re-enabled around suspend_tui/resume_tui so external programs (editor, csvlens) are not affected. 2. Drain all pending events before calling terminal.draw(). Previously the loop rendered after every single event; now it consumes all immediately- available events in a tight inner loop and renders once at the end. Pasting N characters without bracketed paste support now costs 1 render instead of N. update_signature_hint() is also called once per drain cycle rather than once per keystroke. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 79 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f682760..8f39efa 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -12,8 +12,9 @@ use std::time::{Duration, Instant}; use crossterm::{ event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyboardEnhancementFlags, - KeyModifiers, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyboardEnhancementFlags, KeyModifiers, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -574,7 +575,7 @@ impl TuiApp { pub async fn run(mut self) -> Result> { enable_raw_mode()?; let mut stdout = std::io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; // Enable Kitty keyboard protocol so Shift+Enter is distinguishable from Enter. // Silently ignore on terminals that don't support it. let _ = execute!( @@ -599,7 +600,7 @@ impl TuiApp { disable_raw_mode()?; let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); - execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste)?; result } @@ -636,26 +637,43 @@ impl TuiApp { terminal.draw(|f| self.render(f))?; - // Poll with a short timeout so the spinner animates even without input + // Poll with a short timeout so the spinner animates even without input. + // Drain ALL immediately-available events before the next render so that + // pasting N characters costs 1 render instead of N. if event::poll(Duration::from_millis(100))? { - match event::read()? { - Event::Key(key) => { - if self.handle_key(key).await { - break; + let mut needs_hint_update = false; + loop { + match event::read()? { + Event::Key(key) => { + if self.handle_key(key).await { + self.should_quit = true; + } + needs_hint_update = true; } - self.update_signature_hint(); - } - - Event::Mouse(mouse) => match mouse.kind { - MouseEventKind::ScrollUp => self.output.scroll_up(8), - MouseEventKind::ScrollDown => self.output.scroll_down(8), - MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - self.handle_mouse_click(mouse.column, mouse.row); + // Bracketed paste: the terminal bundles the entire pasted + // string into one event — insert it all before re-rendering. + Event::Paste(text) => { + self.handle_paste(&text); + needs_hint_update = true; } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => self.output.scroll_up(8), + MouseEventKind::ScrollDown => self.output.scroll_down(8), + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + self.handle_mouse_click(mouse.column, mouse.row); + } + _ => {} + }, + Event::Resize(_, _) => {} // redraw on next tick _ => {} - }, - Event::Resize(_, _) => {} // redraw on next tick - _ => {} + } + // Stop draining if we must quit or no more events are ready. + if self.should_quit || !event::poll(Duration::from_millis(0))? { + break; + } + } + if needs_hint_update { + self.update_signature_hint(); } } @@ -1548,6 +1566,23 @@ impl TuiApp { } } + /// Insert a pasted string into the textarea at the current cursor position. + /// Called for `Event::Paste` (bracketed-paste) events. + /// `\r` is skipped; `\n` becomes a newline; all other chars are inserted normally. + fn handle_paste(&mut self, text: &str) { + if self.is_running { + return; + } + self.completion_state = None; + for ch in text.chars() { + if ch == '\n' { + self.textarea.insert_newline(); + } else if ch != '\r' { + self.textarea.insert_char(ch); + } + } + } + /// Handle a left-mouse-button click at terminal position `(col, row)`. /// /// Priority: @@ -2060,14 +2095,14 @@ impl TuiApp { fn suspend_tui(terminal: &mut Terminal>) { let _ = disable_raw_mode(); let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); - let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste); let _ = std::io::Write::flush(terminal.backend_mut()); } /// Restore raw mode and alternate screen after an external program has /// finished. Pairs with `suspend_tui`. fn resume_tui(terminal: &mut Terminal>) { - let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); + let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste); let _ = execute!( terminal.backend_mut(), PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) From aaa461eecdaf3345ac21ffc374a1340ef34b92b4 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:12:16 +0100 Subject: [PATCH 135/147] Support new routine_parameters struct format and is_variadic in signature hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firebolt Core now returns routine_parameters as an array of structs ({type, name, default_value, comment}) instead of plain type strings, and adds an is_variadic boolean column. - Try new query (with is_variadic) first; fall back to old format on error - Parse new struct format: show "name type" (or just "type" when unnamed) and append "..." to the last param when is_variadic is true - Old format (plain string array) still detected and handled at parse time - Fix != → <> in routines queries (new Firebolt parser rejects !=) - Add unit tests for both old and new formats Co-Authored-By: Claude Sonnet 4.6 --- src/completion/schema_cache.rs | 136 +++++++++++++++++++++++++-------- 1 file changed, 105 insertions(+), 31 deletions(-) diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index f07e308..115d7aa 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -387,17 +387,38 @@ impl SchemaCache { let functions_query = "SELECT routine_name \ FROM information_schema.routines \ - WHERE routine_type != 'OPERATOR' \ + WHERE routine_type <> 'OPERATOR' \ ORDER BY routine_name"; let functions_result = query_silent(context, functions_query).await; - // `routine_parameters` is an array column containing the parameter types - // for each overload, e.g. `["text"]` or `["anyelement","anyelement"]`. - let signatures_query = "SELECT routine_name, routine_parameters \ - FROM information_schema.routines \ - WHERE routine_type != 'OPERATOR' \ - ORDER BY routine_name"; - let signatures_result = query_silent(context, signatures_query).await; + // Try new format first: includes is_variadic; routine_parameters is array of structs. + // Falls back to old format (array of type strings) if new columns aren't available. + let new_sigs_query = "SELECT routine_name, routine_parameters, is_variadic \ + FROM information_schema.routines \ + WHERE routine_type <> 'OPERATOR' \ + ORDER BY routine_name"; + let sigs_raw = query_silent(context, new_sigs_query).await; + let signatures_result: Option = match sigs_raw { + Ok(body) => { + let has_error = body.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .map(|j| j.get("errors").is_some()) + .unwrap_or(false) + }); + if has_error { + // Old server: no is_variadic column; fall back + let old_sigs_query = "SELECT routine_name, routine_parameters \ + FROM information_schema.routines \ + WHERE routine_type <> 'OPERATOR' \ + ORDER BY routine_name"; + query_silent(context, old_sigs_query).await.ok() + } else { + Some(body) + } + } + Err(_) => None, + }; // ── Step 3: parse and populate cache ──────────────────────────────── let mut new_tables = HashMap::new(); @@ -466,13 +487,10 @@ impl SchemaCache { } // Parse function signatures (best-effort; silently ignored on failure) - match signatures_result { - Ok(sig_output) => { - if let Some(sig_map) = Self::parse_function_signatures(&sig_output) { - *self.function_signatures.write().unwrap() = sig_map; - } + if let Some(sig_output) = signatures_result { + if let Some(sig_map) = Self::parse_function_signatures(&sig_output) { + *self.function_signatures.write().unwrap() = sig_map; } - Err(_) => { /* information_schema.parameters not available — that's fine */ } } Ok(()) @@ -630,11 +648,12 @@ impl SchemaCache { } } - /// Parse `(routine_name, routine_parameters)` rows from `information_schema.routines` - /// into a map of lowercase function name → list of signature strings. + /// Parse `(routine_name, routine_parameters[, is_variadic])` rows from + /// `information_schema.routines` into a map of lowercase function name → signature strings. /// - /// `routine_parameters` is a JSON array of parameter type strings per overload, - /// e.g. `["text"]` or `["anyelement", "anyelement"]`. + /// Supports two formats for `routine_parameters`: + /// - **New**: array of structs `[{type, name, default_value, comment}, ...]` (+ `is_variadic`) + /// - **Old**: array of type strings `["text", "integer", ...]` fn parse_function_signatures(output: &str) -> Option>> { let mut sig_map: HashMap> = HashMap::new(); @@ -652,21 +671,57 @@ impl SchemaCache { let rname = arr[0].as_str().unwrap_or("").trim().to_string(); if rname.is_empty() { continue; } - // routine_parameters is a JSON array of type strings - let params: Vec = arr[1] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); + // arr[2] = is_variadic (new format only; absent in old) + let is_variadic = arr.get(2) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let params: Vec = if let Some(param_arr) = arr[1].as_array() { + if param_arr.is_empty() { + vec![] + } else if param_arr[0].is_object() { + // New format: [{type, name, default_value, comment}] + param_arr.iter().enumerate().map(|(i, p)| { + let typ = p.get("type").and_then(|v| v.as_str()).unwrap_or("?"); + let name = p.get("name").and_then(|v| v.as_str()); + let default = p.get("default_value").and_then(|v| v.as_str()); + let is_last = i == param_arr.len() - 1; + + let mut s = if let Some(n) = name { + format!("{} {}", n, typ) + } else { + typ.to_string() + }; + + if let Some(d) = default { + if !d.is_empty() { + s = format!("{} = {}", s, d); + } + } + + if is_variadic && is_last { + s = format!("{}...", s); + } + + s + }).collect() + } else { + // Old format: ["text", "integer", ...] + param_arr.iter().enumerate().map(|(i, v)| { + let typ = v.as_str().unwrap_or("?").to_string(); + if is_variadic && i == param_arr.len() - 1 { + format!("{}...", typ) + } else { + typ + } + }).collect() + } + } else { + vec![] + }; let sig = format!("{}({})", rname, params.join(", ")); - let entry = sig_map - .entry(rname.to_lowercase()) - .or_default(); + let entry = sig_map.entry(rname.to_lowercase()).or_default(); if !entry.contains(&sig) { entry.push(sig); } @@ -723,6 +778,25 @@ mod tests { ); } + #[test] + fn test_parse_function_signatures_new_format() { + // New format: routine_parameters is array of structs, with is_variadic + let output = r#"{"message_type":"DATA","data":[["date_add",[{"type":"text","name":"unit","default_value":null,"comment":""},{"type":"integer","name":"value","default_value":null,"comment":""},{"type":"timestamp","name":"date","default_value":null,"comment":""}],false],["coalesce",[{"type":"text","name":null,"default_value":null,"comment":""}],true],["upper",[{"type":"text","name":null,"default_value":null,"comment":""}],false]]}"#; + let sigs = SchemaCache::parse_function_signatures(output).unwrap(); + assert_eq!(sigs["date_add"], vec!["date_add(unit text, value integer, date timestamp)"]); + assert_eq!(sigs["coalesce"], vec!["coalesce(text...)"]); + assert_eq!(sigs["upper"], vec!["upper(text)"]); + } + + #[test] + fn test_parse_function_signatures_old_format() { + // Old format: routine_parameters is array of type strings, no is_variadic + let output = r#"{"message_type":"DATA","data":[["upper",["text"]],["date_add",["text","integer","timestamp"]]]}"#; + let sigs = SchemaCache::parse_function_signatures(output).unwrap(); + assert_eq!(sigs["upper"], vec!["upper(text)"]); + assert_eq!(sigs["date_add"], vec!["date_add(text, integer, timestamp)"]); + } + #[test] fn test_keywords_loaded() { let cache = SchemaCache::new(300); From 463b54afa40833dc939b13ed849100b87e2322dd Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:21:56 +0100 Subject: [PATCH 136/147] Improve function signature popup: wrapping, type colors, => for defaults - Type names now displayed in UPPERCASE (TEXT, INTEGER, TIMESTAMP, ...) - Default values shown with => separator (e.g. `unit TEXT => 'day'`) - Variadic marker (...) placed after the type, before any default - Popup wraps to multiple lines when signature is wider than the terminal: each param on its own line, continuation lines aligned under the opening `(` - Per-token coloring: function name=cyan, param names=white, types=yellow, `=>`=gray, default values=light-gray, parens=dim-white Co-Authored-By: Claude Sonnet 4.6 --- src/completion/schema_cache.rs | 31 ++++--- src/tui/mod.rs | 165 ++++++++++++++++++++++++++------- 2 files changed, 149 insertions(+), 47 deletions(-) diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs index 115d7aa..2d651f1 100644 --- a/src/completion/schema_cache.rs +++ b/src/completion/schema_cache.rs @@ -682,33 +682,36 @@ impl SchemaCache { } else if param_arr[0].is_object() { // New format: [{type, name, default_value, comment}] param_arr.iter().enumerate().map(|(i, p)| { - let typ = p.get("type").and_then(|v| v.as_str()).unwrap_or("?"); + let typ = p.get("type").and_then(|v| v.as_str()).unwrap_or("?").to_uppercase(); let name = p.get("name").and_then(|v| v.as_str()); let default = p.get("default_value").and_then(|v| v.as_str()); let is_last = i == param_arr.len() - 1; + // Build: [name ]TYPE[...] [=> default] let mut s = if let Some(n) = name { - format!("{} {}", n, typ) + if is_variadic && is_last { + format!("{} {}...", n, typ) + } else { + format!("{} {}", n, typ) + } + } else if is_variadic && is_last { + format!("{}...", typ) } else { - typ.to_string() + typ }; if let Some(d) = default { if !d.is_empty() { - s = format!("{} = {}", s, d); + s = format!("{} => {}", s, d); } } - if is_variadic && is_last { - s = format!("{}...", s); - } - s }).collect() } else { // Old format: ["text", "integer", ...] param_arr.iter().enumerate().map(|(i, v)| { - let typ = v.as_str().unwrap_or("?").to_string(); + let typ = v.as_str().unwrap_or("?").to_uppercase(); if is_variadic && i == param_arr.len() - 1 { format!("{}...", typ) } else { @@ -783,9 +786,9 @@ mod tests { // New format: routine_parameters is array of structs, with is_variadic let output = r#"{"message_type":"DATA","data":[["date_add",[{"type":"text","name":"unit","default_value":null,"comment":""},{"type":"integer","name":"value","default_value":null,"comment":""},{"type":"timestamp","name":"date","default_value":null,"comment":""}],false],["coalesce",[{"type":"text","name":null,"default_value":null,"comment":""}],true],["upper",[{"type":"text","name":null,"default_value":null,"comment":""}],false]]}"#; let sigs = SchemaCache::parse_function_signatures(output).unwrap(); - assert_eq!(sigs["date_add"], vec!["date_add(unit text, value integer, date timestamp)"]); - assert_eq!(sigs["coalesce"], vec!["coalesce(text...)"]); - assert_eq!(sigs["upper"], vec!["upper(text)"]); + assert_eq!(sigs["date_add"], vec!["date_add(unit TEXT, value INTEGER, date TIMESTAMP)"]); + assert_eq!(sigs["coalesce"], vec!["coalesce(TEXT...)"]); + assert_eq!(sigs["upper"], vec!["upper(TEXT)"]); } #[test] @@ -793,8 +796,8 @@ mod tests { // Old format: routine_parameters is array of type strings, no is_variadic let output = r#"{"message_type":"DATA","data":[["upper",["text"]],["date_add",["text","integer","timestamp"]]]}"#; let sigs = SchemaCache::parse_function_signatures(output).unwrap(); - assert_eq!(sigs["upper"], vec!["upper(text)"]); - assert_eq!(sigs["date_add"], vec!["date_add(text, integer, timestamp)"]); + assert_eq!(sigs["upper"], vec!["upper(TEXT)"]); + assert_eq!(sigs["date_add"], vec!["date_add(TEXT, INTEGER, TIMESTAMP)"]); } #[test] diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8f39efa..97120c5 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2796,26 +2796,141 @@ impl TuiApp { func_name: &str, sigs: &[String], ) { - use ratatui::{ - style::Modifier, - widgets::{Clear, List, ListItem}, - }; + use ratatui::{style::Modifier, widgets::{Clear, Paragraph}}; + + // ── Colour palette ──────────────────────────────────────────────── + let fname_style = Style::default().fg(Color::LightCyan); + let paren_style = Style::default().fg(Color::Indexed(250)); + let name_style = Style::default().fg(Color::White); + let type_style = Style::default().fg(Color::Yellow); + let arrow_style = Style::default().fg(Color::Indexed(240)); + let dflt_style = Style::default().fg(Color::Indexed(248)); + + // Maximum inner width available for content (leave margins) + let max_w = total.width.saturating_sub(6).max(30) as usize; + + // ── Helpers ─────────────────────────────────────────────────────── + + /// Split "func(p1, p2)" → ("func", ["p1", "p2"]). + /// Handles nested parens in type names (e.g. STRUCT(...)). + fn parse_sig(sig: &str) -> (&str, Vec<&str>) { + let Some(open) = sig.find('(') else { return (sig, vec![]) }; + let fname = &sig[..open]; + let inner = sig[open + 1..].trim_end_matches(')'); + if inner.is_empty() { return (fname, vec![]); } + // Split at ", " respecting nested parens + let mut params = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (i, b) in inner.bytes().enumerate() { + match b { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + params.push(inner[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + params.push(inner[start..].trim()); + (fname, params) + } + + /// Build styled spans for one param string. + /// Format: [name ]TYPE[...] [=> default] + /// A param has a name when its first character is lowercase. + fn style_param( + param: &str, + name_s: Style, + type_s: Style, + arrow_s: Style, + dflt_s: Style, + ) -> Vec> { + let (main, default) = if let Some(idx) = param.find(" => ") { + (¶m[..idx], Some(¶m[idx + 4..])) + } else { + (param, None) + }; + + let has_name = main.chars().next().map(|c| c.is_lowercase()).unwrap_or(false); + let mut spans: Vec> = Vec::new(); + + if has_name { + let sp = main.find(' ').unwrap_or(main.len()); + spans.push(Span::styled(main[..sp].to_owned(), name_s)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(main[sp + 1..].to_owned(), type_s)); + } else { + spans.push(Span::styled(main.to_owned(), type_s)); + } + + if let Some(d) = default { + spans.push(Span::styled(" => ".to_owned(), arrow_s)); + spans.push(Span::styled(d.to_owned(), dflt_s)); + } + spans + } - // Build display lines - let lines: Vec = if sigs.is_empty() { - vec![format!("{}(...)", func_name)] + // ── Build display lines ─────────────────────────────────────────── + let mut all_lines: Vec = Vec::new(); + + let fallback; + let sig_strs: &[String] = if sigs.is_empty() { + fallback = vec![format!("{}()", func_name)]; + &fallback } else { - sigs.to_vec() + sigs }; - let n = lines.len() as u16; - let popup_h = n + 2; // content rows + 2 borders - let popup_w = { - let max_w = lines.iter().map(|l| l.len()).max().unwrap_or(10) as u16 + 4; - max_w.min(total.width.saturating_sub(4)) - }; + for sig in sig_strs { + let (fname, params) = parse_sig(sig); + let fname_owned = fname.to_owned(); + + if sig.len() <= max_w || params.len() <= 1 { + // ── Single line ─────────────────────────────────────────── + let mut spans: Vec = vec![ + Span::styled(fname_owned, fname_style), + Span::styled("(".to_owned(), paren_style), + ]; + for (i, p) in params.iter().enumerate() { + if i > 0 { spans.push(Span::styled(", ".to_owned(), paren_style)); } + spans.extend(style_param(p, name_style, type_style, arrow_style, dflt_style)); + } + spans.push(Span::styled(")".to_owned(), paren_style)); + all_lines.push(Line::from(spans)); + } else { + // ── Multi-line: one param per line, aligned after "(" ───── + let indent = " ".repeat(fname.len() + 1); + for (i, p) in params.iter().enumerate() { + let is_first = i == 0; + let is_last = i == params.len() - 1; + let suffix = if is_last { ")" } else { "," }; + + let mut spans: Vec = Vec::new(); + if is_first { + spans.push(Span::styled(fname_owned.clone(), fname_style)); + spans.push(Span::styled("(".to_owned(), paren_style)); + } else { + spans.push(Span::raw(indent.clone())); + } + spans.extend(style_param(p, name_style, type_style, arrow_style, dflt_style)); + spans.push(Span::styled(suffix.to_owned(), paren_style)); + all_lines.push(Line::from(spans)); + } + } + } - // Anchor: just above the input area, right-aligned within the terminal + // ── Compute popup dimensions ────────────────────────────────────── + let content_w = all_lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::()) + .max() + .unwrap_or(10); + let popup_w = ((content_w + 4) as u16).min(total.width.saturating_sub(2)); + let popup_h = all_lines.len() as u16 + 2; + + // Position: just above the input area, right-aligned let y = input_area.y.saturating_sub(popup_h); let x = total.width.saturating_sub(popup_w + 1); let rect = Rect::new(x, y, popup_w, popup_h); @@ -2824,32 +2939,16 @@ impl TuiApp { let title = Span::styled( format!(" {} ", func_name), - Style::default() - .fg(Color::LightCyan) - .add_modifier(Modifier::BOLD), + Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD), ); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Indexed(67))); - let inner = block.inner(rect); f.render_widget(block, rect); - let items: Vec = lines - .iter() - .map(|sig| { - // Colour: function name in cyan, parens+types in default - let paren = sig.find('(').unwrap_or(sig.len()); - let (fname, rest) = sig.split_at(paren); - ListItem::new(Line::from(vec![ - Span::styled(fname.to_string(), Style::default().fg(Color::LightCyan)), - Span::styled(rest.to_string(), Style::default().fg(Color::White)), - ])) - }) - .collect(); - - f.render_widget(List::new(items), inner); + f.render_widget(Paragraph::new(ratatui::text::Text::from(all_lines)), inner); } fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { From a1b5fb0327e091452d84d5a1717319a68da9f508 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:27:16 +0100 Subject: [PATCH 137/147] Fix signature popup panic when content exceeds terminal height The popup height was not clamped, so with many overloads it could exceed the terminal dimensions, causing a ratatui buffer bounds panic. - Cap popup_h to input_area.y (space above the input pane, the most the popup can occupy without going off-screen) - Also cap against total.height as a hard safety bound - Truncate displayed lines to match the clamped height - Return early when there are fewer than 3 rows available above the input Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 97120c5..9985f9e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2928,7 +2928,17 @@ impl TuiApp { .max() .unwrap_or(10); let popup_w = ((content_w + 4) as u16).min(total.width.saturating_sub(2)); - let popup_h = all_lines.len() as u16 + 2; + + // Clamp height: the popup must fit between row 0 and input_area.y. + // `input_area.y` is the number of rows above the input pane, which is + // the maximum space available for a popup anchored above it. + let max_popup_h = input_area.y.min(total.height); + if max_popup_h < 3 { return; } // not enough room to show anything useful + let popup_h = (all_lines.len() as u16 + 2).min(max_popup_h); + + // Truncate content lines to fit the clamped height (border takes 2 rows). + let max_content = popup_h.saturating_sub(2) as usize; + let all_lines: Vec = all_lines.into_iter().take(max_content).collect(); // Position: just above the input area, right-aligned let y = input_area.y.saturating_sub(popup_h); From 0ff4831775e438888a2528df2e621f2a5d5112ed Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:32:53 +0100 Subject: [PATCH 138/147] Use flow-wrap in signature popup to maximize visible overloads Instead of one-param-per-line, pack as many params as fit on each line (greedy fill). Continuation lines align under the opening '('. This produces fewer display lines per overload, so more overloads fit within the vertical space before the height clamp kicks in. Example with max_w=50: generate_series(start INTEGER, stop INTEGER, step INTEGER) Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 52 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9985f9e..2677ee2 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2887,7 +2887,7 @@ impl TuiApp { let (fname, params) = parse_sig(sig); let fname_owned = fname.to_owned(); - if sig.len() <= max_w || params.len() <= 1 { + if sig.len() <= max_w { // ── Single line ─────────────────────────────────────────── let mut spans: Vec = vec![ Span::styled(fname_owned, fname_style), @@ -2900,23 +2900,43 @@ impl TuiApp { spans.push(Span::styled(")".to_owned(), paren_style)); all_lines.push(Line::from(spans)); } else { - // ── Multi-line: one param per line, aligned after "(" ───── - let indent = " ".repeat(fname.len() + 1); + // ── Flow-wrap: pack as many params per line as fit ──────── + // Continuation lines are indented to align under the first param. + let indent_w = fname.len() + 1; + let indent = " ".repeat(indent_w); + + let mut cur_spans: Vec = vec![ + Span::styled(fname_owned.clone(), fname_style), + Span::styled("(".to_owned(), paren_style), + ]; + let mut cur_w = indent_w; + for (i, p) in params.iter().enumerate() { - let is_first = i == 0; - let is_last = i == params.len() - 1; - let suffix = if is_last { ")" } else { "," }; - - let mut spans: Vec = Vec::new(); - if is_first { - spans.push(Span::styled(fname_owned.clone(), fname_style)); - spans.push(Span::styled("(".to_owned(), paren_style)); - } else { - spans.push(Span::raw(indent.clone())); + let is_last = i == params.len() - 1; + let p_w = p.chars().count(); + // Cost of adding this param: p_w + 1 for ")" or p_w + 2 for ", " + let cost = p_w + if is_last { 1 } else { 2 }; + + if i > 0 && cur_w + cost > max_w { + // Wrap: end current line with trailing comma, start new + cur_spans.push(Span::styled(",".to_owned(), paren_style)); + all_lines.push(Line::from(std::mem::take(&mut cur_spans))); + cur_spans.push(Span::raw(indent.clone())); + cur_w = indent_w; + } else if i > 0 { + cur_spans.push(Span::styled(", ".to_owned(), paren_style)); + cur_w += 2; } - spans.extend(style_param(p, name_style, type_style, arrow_style, dflt_style)); - spans.push(Span::styled(suffix.to_owned(), paren_style)); - all_lines.push(Line::from(spans)); + + cur_spans.extend(style_param(p, name_style, type_style, arrow_style, dflt_style)); + cur_w += p_w; + + if is_last { + cur_spans.push(Span::styled(")".to_owned(), paren_style)); + } + } + if !cur_spans.is_empty() { + all_lines.push(Line::from(cur_spans)); } } } From 56b32c519de50192b4026f4b32063fb83f989186 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:34:33 +0100 Subject: [PATCH 139/147] Don't show signature popup when no signatures are known for the function Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2677ee2..f48c2eb 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1234,7 +1234,11 @@ impl TuiApp { match signature_hint::detect_function_at_cursor(&full_sql, byte_offset) { Some(func_name) => { let sigs = self.schema_cache.get_signatures(&func_name); - self.signature_hint = Some((func_name, sigs)); + if sigs.is_empty() { + self.signature_hint = None; + } else { + self.signature_hint = Some((func_name, sigs)); + } } None => { self.signature_hint = None; From d4790d1215cf5f459621dbc6b1e298d31b1f28f4 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:41:28 +0100 Subject: [PATCH 140/147] Add function parameter name completion with 'param => ' insert When the cursor is inside a function call with known signatures, Tab completion now suggests named parameters (e.g. `unit => `, `value => `) with priority just below in-query column suggestions. - Param names are extracted from all overloads of the current function - Unique names across overloads, in declaration order - Filtered by the current partial word (prefix match, case-insensitive) - Completing inserts ` => ` (with trailing space) - Inserted after column items, before tables/functions in the popup - Only appears when signatures are known (popup is visible) Co-Authored-By: Claude Sonnet 4.6 --- src/tui/mod.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f48c2eb..4442055 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1487,6 +1487,60 @@ impl TuiApp { let (word_start_byte_in_line, mut items) = self.completer.complete_at(line_to_cursor, cursor_col, &full_sql); + // ── Named-parameter completions ─────────────────────────────────────── + // When the cursor is inside a function with known signatures, inject + // `param_name => ` suggestions right after any in-query column items. + { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + + if let Some((_, sigs)) = &self.signature_hint { + let partial = &line_to_cursor[word_start_byte_in_line..cursor_col]; + let partial_lower = partial.to_lowercase(); + + // Collect unique param names across all overloads, in order. + // A token is a name (not a type) when its first char is lowercase + // (types are stored UPPERCASE in signature strings). + let mut seen = std::collections::HashSet::new(); + let mut param_items: Vec = Vec::new(); + + for sig in sigs { + let open = sig.find('(').unwrap_or(sig.len()); + let inner = sig[open + 1..].trim_end_matches(')'); + for param in inner.split(", ") { + // Strip flow-wrap trailing comma and " => default" + let main = param.trim().trim_end_matches(','); + let main = main.split(" => ").next().unwrap_or(main); + if main.chars().next().map(|c| c.is_lowercase()).unwrap_or(false) { + let name = main.split_whitespace().next().unwrap_or(""); + // Strip trailing "..." from variadic names like "val..." + let name = name.trim_end_matches("..."); + if !name.is_empty() + && seen.insert(name.to_owned()) + && name.starts_with(&partial_lower) + { + param_items.push(CompletionItem { + value: format!("{} => ", name), + description: "param".to_string(), + item_type: ItemType::Column, + }); + } + } + } + } + + if !param_items.is_empty() { + // Insert after column items, before tables/functions. + let col_end = items + .iter() + .position(|i| i.description != "column") + .unwrap_or(items.len()); + let tail = items.split_off(col_end); + items.extend(param_items); + items.extend(tail); + } + } + } + if items.is_empty() { return; } From 826f8a0db1ba50578fa5e860149f5b2b4a0c25cc Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Fri, 6 Mar 2026 18:49:00 +0100 Subject: [PATCH 141/147] Fix z-order: render tab completion popup above signature hint popup - completion_popup::popup_area gains bottom_offset param so the popup positions itself above the signature hint when both are visible - render_signature_hint now returns Option so its height can be passed as bottom_offset to the completion popup - Render order flipped: signature hint drawn first (lower z), then completion popup drawn on top (higher z) Co-Authored-By: Claude Sonnet 4.6 --- src/tui/completion_popup.rs | 7 +++++-- src/tui/mod.rs | 34 ++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs index 485a2bf..d380ffd 100644 --- a/src/tui/completion_popup.rs +++ b/src/tui/completion_popup.rs @@ -112,6 +112,7 @@ pub fn popup_area( input_area: Rect, textarea_x: u16, total: Rect, + bottom_offset: u16, ) -> Rect { let visible = (state.items.len() as u16).min(MAX_VISIBLE); let popup_h = visible + BORDER_OVERHEAD; @@ -135,8 +136,10 @@ pub fn popup_area( preferred_x }; - // Popup y: just above the input pane (in the output area) - let y = input_area.y.saturating_sub(popup_h); + // Popup y: just above the input pane (and above any bottom_offset, e.g. + // the signature hint popup that sits directly above the input pane). + let effective_bottom = input_area.y.saturating_sub(bottom_offset); + let y = effective_bottom.saturating_sub(popup_h); Rect::new(x, y.max(0), popup_w, popup_h) } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4442055..ae67566 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2500,7 +2500,23 @@ impl TuiApp { let textarea_area = chunks[1]; self.apply_textarea_highlights(f.buffer_mut(), textarea_area); - // Render completion popup if open (not during history search) + // Signature hint popup: rendered first (lower z-order) so the + // completion popup can paint on top of it. + let sig_hint_h = if self.history_search.is_none() && self.fuzzy_state.is_none() { + if let Some((func_name, sigs)) = &self.signature_hint { + let func_name = func_name.clone(); + let sigs = sigs.clone(); + Self::render_signature_hint(f, layout.input, area, &func_name, &sigs) + .map(|r| r.height) + .unwrap_or(0) + } else { + 0 + } + } else { + 0 + }; + + // Render completion popup above the signature hint (if both visible). self.last_popup_rect = None; if self.history_search.is_none() { if let Some(cs) = &self.completion_state { @@ -2510,6 +2526,7 @@ impl TuiApp { layout.input, chunks[1].x, area, + sig_hint_h, ); completion_popup::render(cs, popup_rect, f); // Record for mouse-click hit testing. @@ -2536,15 +2553,6 @@ impl TuiApp { } } - // Signature hint popup (above input, near where the function name starts) - if let Some((func_name, sigs)) = &self.signature_hint { - if self.history_search.is_none() && self.fuzzy_state.is_none() { - let func_name = func_name.clone(); - let sigs = sigs.clone(); - Self::render_signature_hint(f, layout.input, area, &func_name, &sigs); - } - } - // Fuzzy search overlay (rendered on top of everything) if let Some(fs) = &self.fuzzy_state { let fuzzy_rect = fuzzy_popup::popup_area(layout.input, area); @@ -2853,7 +2861,7 @@ impl TuiApp { total: Rect, func_name: &str, sigs: &[String], - ) { + ) -> Option { use ratatui::{style::Modifier, widgets::{Clear, Paragraph}}; // ── Colour palette ──────────────────────────────────────────────── @@ -3011,7 +3019,7 @@ impl TuiApp { // `input_area.y` is the number of rows above the input pane, which is // the maximum space available for a popup anchored above it. let max_popup_h = input_area.y.min(total.height); - if max_popup_h < 3 { return; } // not enough room to show anything useful + if max_popup_h < 3 { return None; } // not enough room to show anything useful let popup_h = (all_lines.len() as u16 + 2).min(max_popup_h); // Truncate content lines to fit the clamped height (border takes 2 rows). @@ -3037,6 +3045,8 @@ impl TuiApp { f.render_widget(block, rect); f.render_widget(Paragraph::new(ratatui::text::Text::from(all_lines)), inner); + + Some(rect) } fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { From 7a1c74d10b3150a5ba9516e3af68f5a1fcc2e1ec Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sun, 8 Mar 2026 16:00:32 +0100 Subject: [PATCH 142/147] fix: handle --help flag in run() instead of silently entering interactive mode gumdrop sets help=true when --help is passed but does not auto-exit when the struct declares a help field. Add an explicit check after the version check to print the usage string and return 0. Co-Authored-By: Claude Sonnet 4.6 --- src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 2a37dd8..40e5641 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod utils; pub mod viewer; use args::get_args_from; +use gumdrop::Options as _; use auth::maybe_authenticate; use completion::schema_cache::SchemaCache; use completion::usage_tracker::UsageTracker; @@ -42,6 +43,13 @@ pub async fn run(raw_args: Vec) -> i32 { return 0; } + if args.help { + println!("Usage: fb [OPTIONS] [QUERY...]"); + println!(); + println!("{}", args::Args::usage()); + return 0; + } + let mut context = Context::new(args); if let Err(e) = maybe_authenticate(&mut context).await { eprintln!("Error: {}", e); From f5c7953baf9820e0656069c5fc0ee974f3d0232d Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sun, 8 Mar 2026 16:45:41 +0100 Subject: [PATCH 143/147] feat: expose fb_cli_usage_string() C FFI and fix binary name in --help - Add fb_cli_usage_string() that returns the gumdrop options block as a malloc'd C string (caller frees with free()); used by embedding binaries to compose their own help output - Fix --help handler to use raw_args[0] as the program name instead of hardcoded "fb" Co-Authored-By: Claude Sonnet 4.6 --- src/lib.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 40e5641..c324cf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,8 @@ pub async fn run(raw_args: Vec) -> i32 { } if args.help { - println!("Usage: fb [OPTIONS] [QUERY...]"); + let prog = raw_args.first().map(String::as_str).unwrap_or("fb"); + println!("Usage: {prog} [OPTIONS] [QUERY...]"); println!(); println!("{}", args::Args::usage()); return 0; @@ -156,6 +157,15 @@ pub fn exit_code_for(e: &Box) -> i32 { } } +/// Returns the gumdrop-generated options block as a null-terminated C string. +/// The caller is responsible for freeing the pointer with `free()`. +#[no_mangle] +pub extern "C" fn fb_cli_usage_string() -> *mut c_char { + std::ffi::CString::new(args::Args::usage()) + .unwrap_or_default() + .into_raw() +} + /// C-callable entry point. `argc`/`argv` mirror the process argv so the caller /// can inject CLI flags (e.g. `["fb", "--core"]`). #[no_mangle] From 90976715bc476010853c765e86e060d67921edf0 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Sun, 8 Mar 2026 17:13:45 +0100 Subject: [PATCH 144/147] fix: erase spinner before rendering table output The spinner character was being erased after the table was already written to stdout. Since both stderr (spinner) and stdout (table) share the terminal cursor, the backspace sequence landed in the wrong place, leaving the spinner character visible at the start of the first table line. Cancel and await the spinner at the top of the response arm, before any output is produced. The post-select cancel+await is still present to handle the ^C arm where take() was never called. Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/query.rs b/src/query.rs index 062edb4..bb77c66 100644 --- a/src/query.rs +++ b/src/query.rs @@ -432,7 +432,7 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< // Show spinner in non-interactive mode only for client-side formats. let spin_token = CancellationToken::new(); - let maybe_spin = if !context.is_tui() && context.args.should_render_table() { + let mut maybe_spin = if !context.is_tui() && context.args.should_render_table() { let t = spin_token.clone(); Some(task::spawn(async move { spin(t).await; })) } else { @@ -453,6 +453,11 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< error_kind = Some(ErrorKind::SystemError); } response = async_resp => { + // Erase the spinner before producing any output, so the backspace + // sequence lands on the spinner character and not on the table. + spin_token.cancel(); + if let Some(s) = maybe_spin.take() { let _ = s.await; } + let elapsed = start.elapsed(); let mut maybe_request_id: Option = None; From 292443c7bb8a7931192a3f395ea2e17e04f0af30 Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 17 Mar 2026 10:16:10 +0100 Subject: [PATCH 145/147] Fix database name in footer not updating on set/USE DATABASE - set_args: when key == "database", also update context.args.database (previously only args.extra was updated, so the footer kept showing the original value) - ParamUpdate handler: extract database= from incoming extras and sync to args.database (fixes USE DATABASE going through the server-side Firebolt-Update-Parameters header path) Co-Authored-By: Claude Sonnet 4.6 --- src/query.rs | 11 +++++++++++ src/tui/mod.rs | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/query.rs b/src/query.rs index bb77c66..cedf6ce 100644 --- a/src/query.rs +++ b/src/query.rs @@ -212,6 +212,10 @@ pub fn set_args(context: &mut Context, query: &str) -> Result = vec![]; buf.push(format!("{key}={value}")); buf = normalize_extras(buf, true)?; @@ -987,6 +991,13 @@ mod tests { assert!(result); assert!(context.args.extra.iter().any(|e| e == "engine=default")); + // Test setting database updates both args.database and args.extra + let query = "set database = mydb"; + let result = set_args(&mut context, query).unwrap(); + assert!(result); + assert_eq!(context.args.database, "mydb"); + assert!(context.args.extra.iter().any(|e| e == "database=mydb")); + // Test with comments before SET command let query = "-- Setting a parameter\nset test = value"; let result = set_args(&mut context, query).unwrap(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ae67566..0566772 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -708,6 +708,9 @@ impl TuiApp { self.context.last_result = Some(result); } Ok(TuiMsg::ParamUpdate(extras)) => { + if let Some(db) = extras.iter().find_map(|e| e.strip_prefix("database=")) { + self.context.args.database = db.to_string(); + } self.context.args.extra = extras; self.context.update_url(); } From de45d7421d683931f9432c3777591e3f3dedb86f Mon Sep 17 00:00:00 2001 From: Tobias Humig Date: Tue, 17 Mar 2026 10:18:35 +0100 Subject: [PATCH 146/147] Change editor keybind from Ctrl+E to Alt+E Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + src/tui/mod.rs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d6b1db1..def90fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,5 +201,6 @@ fb-cli uses a single `--format` option with a prefix notation to distinguish bet - **Ctrl+C in TUI**: Cancels an in-flight query; does not exit - **Ctrl+D in TUI**: Exits - **Ctrl+V in TUI**: Opens csvlens viewer for the last result directly +- **Alt+E in TUI**: Opens current query in $EDITOR - **Spinner**: Shown during query execution for client-side formats only - **History**: Saved to `~/.firebolt/fb_history` (max 10,000 entries), supports Ctrl+R search diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0566772..d28e3b7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -930,8 +930,8 @@ impl TuiApp { self.open_viewer(); } - // ── Ctrl+E: open in $EDITOR ─────────────────────────────────── - (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => { + // ── Alt+E: open in $EDITOR ──────────────────────────────────── + (KeyCode::Char('e'), m) if m.contains(KeyModifiers::ALT) => { self.pending_editor = true; } @@ -3075,7 +3075,7 @@ impl TuiApp { ("Ctrl+D", "Exit"), ("Ctrl+C", "Cancel input / cancel running query"), ("Ctrl+V", "Open last result in csvlens viewer"), - ("Ctrl+E", "Open current query in $EDITOR"), + ("Alt+E", "Open current query in $EDITOR"), ("Ctrl+R", "Reverse history search"), ("Ctrl+Space", "Fuzzy schema search"), ("Ctrl+Up/Down", "Cycle through history (older / newer)"), @@ -3230,7 +3230,7 @@ impl TuiApp { } else if self.completion_state.is_some() { " Enter accept Tab/↑/↓ navigate Esc close ".to_string() } else { - " Ctrl+H help Ctrl+D exit Ctrl+Space fuzzy Ctrl+E editor Ctrl+V viewer Alt+F format Tab complete ".to_string() + " Ctrl+H help Ctrl+D exit Ctrl+Space fuzzy Alt+E editor Ctrl+V viewer Alt+F format Tab complete".to_string() }; let total = area.width as usize; From 0c19d518c83adda41be7e80ed1a2e21aeb804461 Mon Sep 17 00:00:00 2001 From: Lukas Karnowski <5405930+k-lukas@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:55:48 +0100 Subject: [PATCH 147/147] Fix UTF-8 decoding error for multi-byte sequence at chunk boundary (#13) --- src/query.rs | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 218 insertions(+), 4 deletions(-) diff --git a/src/query.rs b/src/query.rs index cedf6ce..eb2628d 100644 --- a/src/query.rs +++ b/src/query.rs @@ -7,6 +7,101 @@ use std::time::Instant; use tokio::{select, signal, task}; use tokio_util::sync::CancellationToken; +/// Reassembles arbitrarily-chunked byte streams into valid UTF-8 strings, +/// carrying incomplete multi-byte sequences across chunk boundaries. +struct ChunkDecoder { + carry: Vec, + total_decoded: usize, +} + +impl ChunkDecoder { + fn new() -> Self { + Self { + carry: Vec::new(), + total_decoded: 0, + } + } + + /// Feed a chunk of bytes. On success returns the decoded UTF-8 string. + /// On failure returns an error message describing the invalid bytes. + fn feed(&mut self, chunk: &[u8]) -> Result { + let data = if self.carry.is_empty() { + chunk.to_vec() + } else { + let mut combined = std::mem::take(&mut self.carry); + combined.extend_from_slice(chunk); + combined + }; + match std::str::from_utf8(&data) { + Ok(s) => { + self.total_decoded += data.len(); + Ok(s.to_string()) + } + Err(e) => { + let valid_end = e.valid_up_to(); + let remainder = &data[valid_end..]; + if e.error_len().is_none() && remainder.len() <= 3 { + let valid = if valid_end > 0 { + std::str::from_utf8(&data[..valid_end]).unwrap().to_string() + } else { + String::new() + }; + self.total_decoded += valid_end; + self.carry = remainder.to_vec(); + Ok(valid) + } else { + let bad_len = e.error_len().unwrap_or(1); + let window_start = valid_end.saturating_sub(32); + let window_end = (valid_end + bad_len + 32).min(data.len()); + let hex_context: Vec = data[window_start..window_end] + .iter() + .enumerate() + .map(|(i, b)| { + let abs = i + window_start; + if abs >= valid_end && abs < valid_end + bad_len { + format!("[{:02x}]", b) + } else { + format!("{:02x}", b) + } + }) + .collect(); + let valid_prefix = String::from_utf8_lossy(&data[window_start..valid_end]); + Err(format!( + "Invalid UTF-8 in response at byte {} ({} bytes already decoded). \ + Hex near error: {} | Text near error: \"{}\u{fffd}...\"", + self.total_decoded + valid_end, + self.total_decoded, + hex_context.join(" "), + valid_prefix + .chars() + .rev() + .take(64) + .collect::() + .chars() + .rev() + .collect::(), + )) + } + } + } + } + + /// Call when the stream has ended. Returns an error if there are leftover + /// carry bytes (truncated multi-byte sequence). + fn finish(self) -> Result<(), String> { + if self.carry.is_empty() { + Ok(()) + } else { + let hex: Vec = self.carry.iter().map(|b| format!("{:02x}", b)).collect(); + Err(format!( + "Invalid UTF-8 at end of response: stream ended with {} trailing bytes (hex: {})", + self.carry.len(), + hex.join(" "), + )) + } + } +} + /// Extract a human-readable error message from a raw server response body. /// /// Tries to parse as JSON and look for common error fields; falls back to the @@ -540,15 +635,21 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< let mut line_buf = String::new(); let mut stream_err: Option = None; + let mut chunk_decoder = ChunkDecoder::new(); 'stream: loop { match resp.chunk().await { Err(e) => { stream_err = Some(e.to_string()); break 'stream; } - Ok(None) => break 'stream, + Ok(None) => { + if let Err(e) = chunk_decoder.finish() { + stream_err = Some(e); + } + break 'stream; + } Ok(Some(chunk)) => { - match std::str::from_utf8(&chunk) { - Err(_) => { stream_err = Some("Invalid UTF-8 in response".into()); break 'stream; } - Ok(s) => line_buf.push_str(s), + match chunk_decoder.feed(&chunk) { + Ok(s) => line_buf.push_str(&s), + Err(e) => { stream_err = Some(e); break 'stream; } } while let Some(nl) = line_buf.find('\n') { let line = line_buf[..nl].trim().to_string(); @@ -1520,4 +1621,117 @@ mod tests { apply_update_parameters(&mut ctx, "transaction_id=cafebabe,transaction_sequence_id=0").unwrap(); assert!(ctx.in_transaction(), "second transaction must register correctly"); } + + #[test] + fn test_chunk_decoder_pure_ascii() { + let mut dec = ChunkDecoder::new(); + assert_eq!(dec.feed(b"hello ").unwrap(), "hello "); + assert_eq!(dec.feed(b"world").unwrap(), "world"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_multibyte_not_split() { + let mut dec = ChunkDecoder::new(); + // "─" is e2 94 80 in UTF-8 + assert_eq!(dec.feed("hello ─ world".as_bytes()).unwrap(), "hello ─ world"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_two_byte_char() { + let mut dec = ChunkDecoder::new(); + // "ñ" is c3 b1 — split across two chunks + let full = "señor".as_bytes(); + let split = full.iter().position(|&b| b == 0xc3).unwrap(); + assert_eq!(dec.feed(&full[..split]).unwrap(), "se"); + assert_eq!(dec.feed(&full[split..]).unwrap(), "ñor"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_three_byte_char_after_first() { + let mut dec = ChunkDecoder::new(); + // "┴" is e2 94 b4 — split after first byte + let full = "ab┴cd".as_bytes(); + let split = 2; // "ab" is 2 bytes, then e2 starts + assert_eq!(dec.feed(&full[..split + 1]).unwrap(), "ab"); + assert_eq!(dec.feed(&full[split + 1..]).unwrap(), "┴cd"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_three_byte_char_after_second() { + let mut dec = ChunkDecoder::new(); + // "┴" is e2 94 b4 — split after second byte + let full = "ab┴cd".as_bytes(); + let split = 2; // "ab" is 2 bytes, then e2 94 b4 + assert_eq!(dec.feed(&full[..split + 2]).unwrap(), "ab"); + assert_eq!(dec.feed(&full[split + 2..]).unwrap(), "┴cd"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_four_byte_char() { + let mut dec = ChunkDecoder::new(); + // "😀" is f0 9f 98 80 — split after first byte + let full = "a😀b".as_bytes(); + assert_eq!(dec.feed(&full[..2]).unwrap(), "a"); + assert_eq!(dec.feed(&full[2..]).unwrap(), "😀b"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_many_splits_across_chunks() { + let mut dec = ChunkDecoder::new(); + let input = "──┬──┴──"; + let bytes = input.as_bytes(); + let mut output = String::new(); + for byte in bytes { + output.push_str(&dec.feed(&[*byte]).unwrap()); + } + assert!(dec.finish().is_ok()); + assert_eq!(output, input); + } + + #[test] + fn test_chunk_decoder_truncated_stream() { + let mut dec = ChunkDecoder::new(); + // Feed the first byte of a 3-byte sequence, then end stream + assert_eq!(dec.feed(&[0xe2]).unwrap(), ""); + let err = dec.finish().unwrap_err(); + assert!(err.contains("trailing bytes"), "error: {}", err); + assert!(err.contains("e2"), "error should contain hex: {}", err); + } + + #[test] + fn test_chunk_decoder_invalid_byte() { + let mut dec = ChunkDecoder::new(); + // 0xff is never valid in UTF-8 + let err = dec.feed(&[b'a', b'b', 0xff, b'c']).unwrap_err(); + assert!(err.contains("Invalid UTF-8"), "error: {}", err); + assert!(err.contains("[ff]"), "error should bracket the bad byte: {}", err); + } + + #[test] + fn test_chunk_decoder_invalid_continuation() { + let mut dec = ChunkDecoder::new(); + // e2 followed by 0xff — not a valid continuation byte + let err = dec.feed(b"hello\xe2\xff").unwrap_err(); + assert!(err.contains("Invalid UTF-8"), "error: {}", err); + } + + #[test] + fn test_chunk_decoder_error_reports_total_position() { + let mut dec = ChunkDecoder::new(); + // First chunk: 10 valid ASCII bytes + assert_eq!(dec.feed(b"0123456789").unwrap(), "0123456789"); + // Second chunk: 5 valid bytes then invalid + let err = dec.feed(&[b'a', b'b', b'c', b'd', b'e', 0xff]).unwrap_err(); + assert!( + err.contains("at byte 15"), + "should report global byte position 15 (10+5): {}", + err + ); + } }