From 17cb510a8078ebd70bcc77c0334d74726f8ef98c Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 22:01:46 +0900 Subject: [PATCH 1/5] chore: use jaq=3.0.0 --- Cargo.lock | 296 +++++++++++++++++++++------------------------------- Cargo.toml | 6 +- src/json.rs | 35 +++++-- 3 files changed, 146 insertions(+), 191 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae940c6..26e7867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ dependencies = [ "memchr", ] -[[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 = "anstream" version = "1.0.0" @@ -125,10 +116,15 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "bstr" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] [[package]] name = "bytemuck" @@ -164,17 +160,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link", -] - [[package]] name = "clap" version = "4.6.0" @@ -239,12 +224,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "crc32fast" version = "1.5.0" @@ -647,33 +626,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hifijson" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7763b98ba8a24f59e698bf9ab197e7676c640d6455d1580b4ce7dc560f0f0d" - -[[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" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "242402749acf71e6f32f5857598b7002c4058a4e3c3b22b4c7d51cab9aea754e" [[package]] name = "image" @@ -713,9 +668,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jaq-core" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77526a72eb79412c29fd141767a6549bbfcb1cb40e00556fe16532d5e878e098" +checksum = "dca0f164c8e9c55fc5aefe60b371df735c719b09930dac185878f2f8c7ab6b68" dependencies = [ "dyn-clone", "once_cell", @@ -724,34 +679,81 @@ dependencies = [ [[package]] name = "jaq-json" -version = "1.1.3" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01dbdbd07b076e8403abac68ce7744d93e2ecd953bbc44bf77bf00e1e81172bc" +checksum = "f5c5baabe63d1d72cde60ec7548a098036773e3541dbc65c6a44fb38e9cfb272" dependencies = [ + "bstr", + "bytes", "foldhash", "hifijson", "indexmap", "jaq-core", "jaq-std", - "serde_json", + "num-bigint", + "num-traits", + "ryu", + "self_cell", ] [[package]] name = "jaq-std" -version = "2.1.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c264fe397c981705976c71f1bfe020382b9eda52ae950e57fe885e147bdd67d" +checksum = "2a11bb307027b20b3dc7b212ad687e7e410cbc43933eec5d498672ab2bc60666" dependencies = [ "aho-corasick", "base64", - "chrono", + "bstr", "jaq-core", + "jiff", "libm", "log", - "regex-lite", + "regex-bites", "urlencoding", ] +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jnv" version = "0.7.1" @@ -773,16 +775,6 @@ dependencies = [ "toml", ] -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - [[package]] name = "libc" version = "0.2.183" @@ -878,6 +870,25 @@ dependencies = [ "memchr", ] +[[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-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1053,6 +1064,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1161,10 +1187,16 @@ dependencies = [ ] [[package]] -name = "regex-lite" -version = "0.1.9" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" + +[[package]] +name = "regex-bites" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a15a2fa0bfda9361941c45550896ae87b15cc6c8c939ea350079670332e211" [[package]] name = "rustc_version" @@ -1189,10 +1221,10 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -1200,6 +1232,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -1526,51 +1564,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - [[package]] name = "wayland-backend" version = "0.3.14" @@ -1669,65 +1662,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 7d5489e..e330c03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,9 @@ tokio = { version = "1.50.0", features = ["full"] } toml = "1.1.0" # jaq dependencies -jaq-core = "2.2.1" -jaq-json = { version = "1.1.3", features = ["serde_json"] } -jaq-std = "2.1.2" +jaq-core = "3.0.0" +jaq-json = "2.0.0" +jaq-std = "3.0.0" [dependencies.promkit-widgets] version = "0.5.0" diff --git a/src/json.rs b/src/json.rs index f12a377..9036aa8 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,8 +1,9 @@ use jaq_core::{ + data, load::{Arena, File, Loader}, - Compiler, Ctx, RcIter, + unwrap_valr, Compiler, Ctx, Vars, }; -use jaq_json::Val; +use jaq_json::{read, Val}; use promkit_widgets::{ jsonstream::jsonz, @@ -40,7 +41,8 @@ pub fn run_jaq( json_stream: &[serde_json::Value], ) -> anyhow::Result> { let arena = Arena::default(); - let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs())); + let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs()); + let loader = Loader::new(defs); let modules = loader .load( &arena, @@ -50,21 +52,34 @@ pub fn run_jaq( }, ) .map_err(|errs| anyhow::anyhow!("jq filter parsing failed: {errs:?}"))?; + let funs = jaq_core::funs::>() + .chain(jaq_std::funs::>()) + .chain(jaq_json::funs::>()); let filter = Compiler::default() - .with_funs(jaq_std::funs().chain(jaq_json::funs())) + .with_funs(funs) .compile(modules) .map_err(|errs| anyhow::anyhow!("jq filter compilation failed: {errs:?}"))?; let mut ret = Vec::::new(); for input in json_stream { - let inputs = RcIter::new(core::iter::empty()); - let out = filter.run((Ctx::new([], &inputs), Val::from(input.clone()))); + let input = serde_json::to_vec(input) + .map_err(|e| anyhow::anyhow!("failed to serialize input JSON: {e}"))?; + let input = read::parse_single(&input) + .map_err(|e| anyhow::anyhow!("failed to parse input into jaq value: {e}"))?; + + let ctx = Ctx::>::new(&filter.lut, Vars::new([])); + let out = filter.id.run((ctx, input)).map(unwrap_valr); + for item in out { - match item { - Ok(val) => ret.push(val.into()), - Err(err) => return Err(anyhow::anyhow!("jq filter execution failed: {err}")), - } + let val = item.map_err(|err| anyhow::anyhow!("jq filter execution failed: {err}"))?; + let text = val.to_string(); + let json = serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "failed to convert jaq output to JSON value (possibly non-JSON output): {e}" + ) + })?; + ret.push(json); } } From f9d995e0e794aa8d63688aa466a1a8f901b30d96 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 22:46:30 +0900 Subject: [PATCH 2/5] cargo-fmt --- src/json.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/json.rs b/src/json.rs index 9036aa8..9c6a90d 100644 --- a/src/json.rs +++ b/src/json.rs @@ -41,7 +41,9 @@ pub fn run_jaq( json_stream: &[serde_json::Value], ) -> anyhow::Result> { let arena = Arena::default(); - let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs()); + let defs = jaq_core::defs() + .chain(jaq_std::defs()) + .chain(jaq_json::defs()); let loader = Loader::new(defs); let modules = loader .load( From 5feaa3fda881a90d097519dfd53e08f892e190bc Mon Sep 17 00:00:00 2001 From: Lauri Gates Date: Tue, 23 Jun 2026 15:40:47 +0300 Subject: [PATCH 3/5] feat(completion): context-aware completion after pipes Completion matched the entire query against root-relative paths, so anything past the first segment (`.foo | .ba`, `.items[] | .na`) yielded nothing. Segment the query at the last top-level `|` (a small scanner that skips string literals and `()`/`[]`/`{}` nesting and ignores `|=`), then for the piped case evaluate the base expression with the existing run_jaq and enumerate its output's paths with get_all_paths, so suggestions are relative to the piped value. The editor text is rebuilt as preserved_prefix + suggestion to keep the pre-pipe context. The parsed input is cached so a piped completion does not re-deserialize the document per keypress. The root (no-pipe) path is unchanged. Closes #114. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BLHuDq5UHVEYsb9UfPQBUJ --- src/completion.rs | 75 +++++++++++++++++++++---- src/completion_ctx.rs | 124 ++++++++++++++++++++++++++++++++++++++++++ src/json.rs | 59 +++++++++++++++++++- src/main.rs | 3 + src/query_editor.rs | 2 +- 5 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 src/completion_ctx.rs diff --git a/src/completion.rs b/src/completion.rs index 419f016..bd5eed4 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -3,6 +3,7 @@ use std::{collections::BTreeSet, sync::Arc}; use promkit_widgets::{ core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, listbox::{self, Listbox}, + serde_json, }; use tokio::{ sync::{mpsc, Mutex, RwLock}, @@ -111,6 +112,16 @@ pub struct CompletionNavigator { search_result_chunk_size: usize, /// Buffered suggestions that are not yet visible in the listbox. remaining_items: Vec, + /// Raw input document, for evaluating the base expression of a piped query. + input: &'static str, + /// Max JSON streams to consider when evaluating the base expression. + max_streams: Option, + /// Lazily-parsed input stream, cached so a piped completion does not + /// re-deserialize the whole document on every keypress. + cached_stream: Option>, + /// Query text up to the start of the segment being completed; prepended to + /// each suggestion so the editor keeps the `.foo | ` context intact. + preserved_prefix: String, } impl CompletionNavigator { @@ -118,18 +129,36 @@ impl CompletionNavigator { shared_suggestions: SharedSuggestionStore, state: listbox::State, search_result_chunk_size: usize, + input: &'static str, + max_streams: Option, ) -> Self { Self { shared_suggestions, state, search_result_chunk_size, remaining_items: Default::default(), + input, + max_streams, + cached_stream: None, + preserved_prefix: String::new(), } } - /// Get the currently selected item in listbox. + /// Get the currently selected item in listbox, with the preserved query + /// prefix prepended so the editor keeps the pre-pipe context. fn get_current_item(&self) -> String { - self.state.listbox.get().to_string() + format!("{}{}", self.preserved_prefix, self.state.listbox.get()) + } + + /// Parsed input stream, deserialized once on first use and cached. Returns + /// an empty slice if the input fails to parse (the caller then offers no + /// suggestions rather than crashing). + fn input_stream(&mut self) -> &[serde_json::Value] { + if self.cached_stream.is_none() { + self.cached_stream = + Some(json::deserialize(self.input, self.max_streams).unwrap_or_default()); + } + self.cached_stream.as_deref().unwrap_or_default() } /// Create graphemes for rendering the completion navigator. @@ -198,8 +227,34 @@ impl CompletionNavigator { None } - async fn enter(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { - let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; + async fn enter(&mut self, query: &str) -> (Option, SuggestionLoadProgress) { + let ctx = crate::completion_ctx::analyze(query); + self.preserved_prefix = query[..ctx.segment_start].to_string(); + let segment = &query[ctx.segment_start..]; + + let (items, progress) = if ctx.base.is_empty() { + // Root context: use the precomputed, incrementally-loaded path store. + self.shared_suggestions.collect_matches(segment).await + } else { + // Piped/context: evaluate the base expression and enumerate paths of + // its output, so suggestions are relative to the piped value. + let stream = self.input_stream(); + let items = match json::relative_paths(&ctx.base, stream) { + Ok(mut paths) => { + paths.retain(|p| p.starts_with(segment)); + paths.sort(); + paths.dedup(); + paths + } + // Base is invalid or still being typed: offer nothing, don't crash. + Err(_) => Vec::new(), + }; + let progress = SuggestionLoadProgress { + is_complete: true, + loaded_path_count: items.len(), + }; + (items, progress) + }; let head_item = self.initialize_session_items(items); (head_item, progress) } @@ -218,7 +273,7 @@ impl CompletionNavigator { .collect::>(); self.remaining_items = items; self.state.listbox = Listbox::from(used); - Some(self.state.listbox.get().to_string()) + Some(self.get_current_item()) } /// Reset completion session state. @@ -230,8 +285,8 @@ impl CompletionNavigator { } pub enum CompletionAction { - /// Triggered when the user enters the completion view with a current query as prefix. - Enter { prefix: String }, + /// Triggered when the user enters the completion view with the current query. + Enter { query: String }, /// Triggered when the user leaves the completion view. Leave, /// Triggered on user input events within the completion view, such as navigation keys. @@ -256,8 +311,8 @@ pub fn start_completion_task( let completion_view = { let mut completion = shared_completion.write().await; match action { - CompletionAction::Enter { prefix } => { - let (head_item, load_progress) = completion.enter(&prefix).await; + CompletionAction::Enter { query } => { + let (head_item, load_progress) = completion.enter(&query).await; match head_item { Some(head) => { let message = if load_progress.is_complete { @@ -272,7 +327,7 @@ pub fn start_completion_task( } None => { guide_action_tx - .send(GuideAction::Show(GuideMessage::NoSuggestionFound(prefix))) + .send(GuideAction::Show(GuideMessage::NoSuggestionFound(query))) .await?; shared_ctx.set_active_index(Index::QueryEditor).await; completion.clear_session_state(); diff --git a/src/completion_ctx.rs b/src/completion_ctx.rs new file mode 100644 index 0000000..ec1f315 --- /dev/null +++ b/src/completion_ctx.rs @@ -0,0 +1,124 @@ +//! Prototype: determine the completion context within a jq query. +//! +//! Splits a query into (base_expr, segment) at the last *top-level* pipe, +//! ignoring pipes inside string literals and parentheses. The `base_expr` +//! is fed to run_jaq; the `segment` is the trailing path being completed. + +#[derive(Debug, PartialEq, Eq)] +pub struct CompletionCtx { + /// Expression before the last top-level pipe (to evaluate). Empty => root. + pub base: String, + /// Byte offset where the segment-to-complete begins (after pipe + whitespace). + pub segment_start: usize, +} + +/// Find the byte index of the last top-level `|` that is a pipe (not `|=`), +/// skipping string literals and any `(...)`/`[...]`/`{...}` nesting. +fn last_top_level_pipe(q: &str) -> Option { + let bytes = q.as_bytes(); + let mut depth: i32 = 0; + let mut in_str = false; + let mut escaped = false; + let mut last_pipe = None; + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if in_str { + if escaped { + escaped = false; + } else if c == b'\\' { + escaped = true; + } else if c == b'"' { + in_str = false; + } + i += 1; + continue; + } + match c { + b'"' => in_str = true, + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b'|' if depth == 0 => { + // `|=` is update-assignment, not a pipe boundary we complete after. + let next_eq = bytes.get(i + 1) == Some(&b'='); + if !next_eq { + last_pipe = Some(i); + } + } + _ => {} + } + i += 1; + } + last_pipe +} + +pub fn analyze(query: &str) -> CompletionCtx { + match last_top_level_pipe(query) { + Some(p) => { + let base = query[..p].trim().to_string(); + // segment begins after the pipe, skipping whitespace + let after = p + 1; + let ws = query[after..].len() - query[after..].trim_start().len(); + CompletionCtx { + base, + segment_start: after + ws, + } + } + None => { + // no pipe: whole query is the segment (skip leading ws) + let ws = query.len() - query.trim_start().len(); + CompletionCtx { + base: String::new(), + segment_start: ws, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seg<'a>(q: &'a str, ctx: &CompletionCtx) -> &'a str { + &q[ctx.segment_start..] + } + + #[test] + fn table() { + let cases = [ + // (query, expected base, expected segment) + (".foo | .ba", ".foo", ".ba"), + (".foo|.ba", ".foo", ".ba"), + (".", "", "."), + (".foo.ba", "", ".foo.ba"), + (".a | .b | .c", ".a | .b", ".c"), + ("(.a | .b) | .c", "(.a | .b)", ".c"), + (".items[] | .na", ".items[]", ".na"), + (r#".foo["a|b"] | .c"#, r#".foo["a|b"]"#, ".c"), + ("{a: .x | .y} | .z", "{a: .x | .y}", ".z"), // pipe inside object value + (".foo | ", ".foo", ""), + (".foo |= .bar", "", ".foo |= .bar"), // |= not a boundary + (" .foo", "", ".foo"), // leading ws + ("map(.a) | .b", "map(.a)", ".b"), + ]; + let mut failures = vec![]; + for (q, want_base, want_seg) in cases { + let ctx = analyze(q); + let got_seg = seg(q, &ctx); + if ctx.base != want_base || got_seg != want_seg { + failures.push(format!( + "query {q:?}: got base={:?} seg={:?}, want base={:?} seg={:?}", + ctx.base, got_seg, want_base, want_seg + )); + } + } + for f in &failures { + eprintln!("FAIL: {f}"); + } + assert!( + failures.is_empty(), + "{} segmentation cases failed", + failures.len() + ); + } +} diff --git a/src/json.rs b/src/json.rs index 9c6a90d..15fc391 100644 --- a/src/json.rs +++ b/src/json.rs @@ -17,8 +17,12 @@ pub async fn get_all_paths( max_streams: Option, ) -> anyhow::Result> { let stream = deserialize(json_str, max_streams)?; - let paths = jsonz::get_all_paths(stream.iter()).collect::>(); - Ok(paths.into_iter()) + Ok(paths_of(&stream).into_iter()) +} + +/// Enumerate all JSON paths reachable in the given parsed values. +pub fn paths_of(values: &[serde_json::Value]) -> Vec { + jsonz::get_all_paths(values.iter()).collect() } /// Deserialize JSON string into a vector of serde_json::Value. @@ -87,3 +91,54 @@ pub fn run_jaq( Ok(ret) } + +/// Evaluate `base` against the already-parsed input stream, then enumerate the +/// JSON paths of the result. Used for context-aware completion after a pipe: +/// suggestions are relative to the output of the base expression rather than the +/// root document. Takes a pre-parsed stream so callers can cache deserialization. +pub fn relative_paths(base: &str, stream: &[serde_json::Value]) -> anyhow::Result> { + let intermediate = run_jaq(base, stream)?; + Ok(paths_of(&intermediate)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(s: &str) -> Vec { + deserialize(s, None).unwrap() + } + + #[test] + fn root_paths_use_dot_and_index_notation() { + let mut p = paths_of(&parse(r#"{"foo": {"bar": 1}, "qux": [10, 20]}"#)); + p.sort(); + assert_eq!(p, [".", ".foo", ".foo.bar", ".qux", ".qux[0]", ".qux[1]"]); + } + + #[test] + fn relative_paths_are_relative_to_base_output() { + let stream = parse(r#"{"foo": {"bar": 1, "baz": 2}, "qux": 9}"#); + let mut p = relative_paths(".foo", &stream).unwrap(); + p.sort(); + // Keys of `.foo`, not of the root document (no `.qux`). + assert_eq!(p, [".", ".bar", ".baz"]); + } + + #[test] + fn relative_paths_descend_into_array_elements() { + // `.items[] | .` — the element-relative case (map/select interiors). + let stream = parse(r#"{"items": [{"name": "a", "qty": 1}]}"#); + let mut p = relative_paths(".items[]", &stream).unwrap(); + p.sort(); + assert_eq!(p, [".", ".name", ".qty"]); + } + + #[test] + fn relative_paths_errors_on_invalid_base() { + let stream = parse(r#"{"foo": 1}"#); + // An incomplete/invalid base expression must surface as an error so the + // caller can fall back to offering no suggestions rather than crashing. + assert!(relative_paths("this is not jq (", &stream).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 15807d7..c68981f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ use tokio::sync::{mpsc, RwLock}; mod completion; use completion::{CompletionAction, CompletionNavigator}; +mod completion_ctx; mod config; use config::{Config, DEFAULT_CONFIG}; mod context; @@ -209,6 +210,8 @@ async fn main() -> anyhow::Result<()> { config: config.completion.listbox, }, config.completion.search_result_chunk_size, + input, + config.json.max_streams, ); // Initialize the query editor with the default filter, configuration, and keybindings. diff --git a/src/query_editor.rs b/src/query_editor.rs index 54dac54..ed8ada7 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -187,7 +187,7 @@ pub fn start_query_editor_task( shared_ctx.set_active_index(Index::Completion).await; completion_action_tx .send(CompletionAction::Enter { - prefix: editor.text(), + query: editor.text(), }) .await?; } From ebeaa62970b492eedca92b287169f94e00e49538 Mon Sep 17 00:00:00 2001 From: Lauri Gates Date: Wed, 24 Jun 2026 17:44:19 +0300 Subject: [PATCH 4/5] refactor(completion): segment queries via jaq token trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled byte scanner in `last_top_level_pipe` with jaq's own tokenizer (`jaq_core::load::lex`). The scanner re-implemented a tiny jq lexer — tracking string-literal/escape state, `()`/`[]`/`{}` nesting depth, and `|=` lookahead by hand — which @01mf02 objected to in the #114 design thread ("where you basically re-lex the jq script manually"). The lexer groups each delimited group into a recursive `Tok::Block` and string literals/interpolation into `Tok::Str`, so a `|` nested in any of those is simply not a top-level token; `|=` lexes as a distinct `Tok::Sym`. "Last top-level pipe" becomes "last `Tok::Sym == "|"` in the top-level token vec", with the byte offset recovered from the slice pointer. All the manual bookkeeping is deleted, and segmentation can no longer disagree with how jq parses the query. Faithful port: the `analyze() -> CompletionCtx` contract and all existing tests are unchanged. A lex failure on unbalanced mid-typing input (e.g. `select(.a` before TAB) degrades to the root context — never worse than the old scanner, which produced an invalid base that yielded empty suggestions anyway. Cursor-awareness and in-paren completion remain explicit follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/completion_ctx.rs | 69 +++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/src/completion_ctx.rs b/src/completion_ctx.rs index ec1f315..dd81c89 100644 --- a/src/completion_ctx.rs +++ b/src/completion_ctx.rs @@ -3,6 +3,16 @@ //! Splits a query into (base_expr, segment) at the last *top-level* pipe, //! ignoring pipes inside string literals and parentheses. The `base_expr` //! is fed to run_jaq; the `segment` is the trailing path being completed. +//! +//! Segmentation uses jaq's own tokenizer (`jaq_core::load::lex`) rather than a +//! hand-rolled byte scanner, so it can never disagree with how jq parses the +//! query. The lexer already handles string literals/escapes/interpolation +//! (`Tok::Str`) and groups each `(...)`/`[...]`/`{...}` into one recursive +//! `Tok::Block`; a `|` nested inside any of those is simply not a top-level +//! token. The `|=` update-assignment lexes as a distinct `Tok::Sym` (`"|="`), +//! so excluding it is free. + +use jaq_core::load::lex::{Lexer, Tok}; #[derive(Debug, PartialEq, Eq)] pub struct CompletionCtx { @@ -12,44 +22,23 @@ pub struct CompletionCtx { pub segment_start: usize, } -/// Find the byte index of the last top-level `|` that is a pipe (not `|=`), -/// skipping string literals and any `(...)`/`[...]`/`{...}` nesting. -fn last_top_level_pipe(q: &str) -> Option { - let bytes = q.as_bytes(); - let mut depth: i32 = 0; - let mut in_str = false; - let mut escaped = false; - let mut last_pipe = None; - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - if in_str { - if escaped { - escaped = false; - } else if c == b'\\' { - escaped = true; - } else if c == b'"' { - in_str = false; - } - i += 1; - continue; - } - match c { - b'"' => in_str = true, - b'(' | b'[' | b'{' => depth += 1, - b')' | b']' | b'}' => depth -= 1, - b'|' if depth == 0 => { - // `|=` is update-assignment, not a pipe boundary we complete after. - let next_eq = bytes.get(i + 1) == Some(&b'='); - if !next_eq { - last_pipe = Some(i); - } - } - _ => {} - } - i += 1; - } - last_pipe +/// Find the byte offset of the last top-level `|` pipe symbol (not `|=`), +/// using jaq's lexer. +/// +/// Returns `None` when there is no top-level pipe, or when the query fails to +/// lex (e.g. unbalanced delimiters during mid-typing like `select(.a`); callers +/// then degrade to the root context. This is never worse than the old byte +/// scanner, which "succeeded" on such input but produced an invalid `base` that +/// downstream evaluation rejected into empty suggestions anyway. +fn last_top_level_pipe(query: &str) -> Option { + // Walk only the top-level token vec; never descend into `Tok::Block`. + let tokens = Lexer::new(query).lex().ok()?; + let last = tokens + .iter() + .rfind(|tok| matches!(tok.1, Tok::Sym) && tok.0 == "|")?; + // Recover the slice's byte offset within `query` (the same computation as + // jaq's private `load::span`, inlined to avoid depending on its visibility). + Some(last.0.as_ptr() as usize - query.as_ptr() as usize) } pub fn analyze(query: &str) -> CompletionCtx { @@ -100,6 +89,10 @@ mod tests { (".foo |= .bar", "", ".foo |= .bar"), // |= not a boundary (" .foo", "", ".foo"), // leading ws ("map(.a) | .b", "map(.a)", ".b"), + // token-tree specifics: + (".a.", "", ".a."), // open trailing dot lexes cleanly (`.a`, `.`) + ("select(.a", "", "select(.a"), // unbalanced: degrades to root, no panic + (r#""\(.a|.b)" | .c"#, r#""\(.a|.b)""#, ".c"), // pipe inside string interpolation ]; let mut failures = vec![]; for (q, want_base, want_seg) in cases { From 3cd488ce8a33ace07a90b035d7a49df676a04c37 Mon Sep 17 00:00:00 2001 From: Lauri Gates Date: Wed, 24 Jun 2026 21:37:53 +0300 Subject: [PATCH 5/5] feat(completion): cursor-aware segmentation (lex left of cursor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completion previously analyzed the whole query, implicitly assuming the cursor sat at the end. Segment only the text left of the cursor and preserve the text to its right, so TAB at `.items[] | .na‸me` completes `.na` to `.name` and keeps `me` after the cursor, landing the cursor right after the inserted suggestion. - completion_ctx: add `segment_at_cursor(query, cursor)`, a pure wrapper that slices at the cursor byte offset and reuses the unchanged token-tree `analyze` on the left side. Cursor-at-end is byte-identical to today (suffix empty), so existing behavior is regression-safe. - query_editor: convert promkit's char-index cursor to a byte offset (`text_and_cursor_byte`), send it with the completion trigger, and splice suggestions mid-line via `replace_text_at` (cursor lands after the suggestion, suffix preserved). - completion: thread the cursor + preserved suffix through `enter` and `get_current_item` so both the initial TAB result and suggestion cycling are cursor-aware. The in-paren `select(...)` case remains a separate follow-up (needs jaq's unreleased `yield` branch). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/completion.rs | 59 ++++++++++++++++++++++-------------- src/completion_ctx.rs | 69 +++++++++++++++++++++++++++++++++++++++++++ src/query_editor.rs | 66 +++++++++++++++++++++++++++++++++++------ 3 files changed, 163 insertions(+), 31 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index bd5eed4..4fbebf7 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -122,6 +122,9 @@ pub struct CompletionNavigator { /// Query text up to the start of the segment being completed; prepended to /// each suggestion so the editor keeps the `.foo | ` context intact. preserved_prefix: String, + /// Query text right of the cursor, preserved verbatim and appended after + /// each suggestion so mid-line completion keeps the trailing text intact. + preserved_suffix: String, } impl CompletionNavigator { @@ -141,13 +144,18 @@ impl CompletionNavigator { max_streams, cached_stream: None, preserved_prefix: String::new(), + preserved_suffix: String::new(), } } - /// Get the currently selected item in listbox, with the preserved query - /// prefix prepended so the editor keeps the pre-pipe context. - fn get_current_item(&self) -> String { - format!("{}{}", self.preserved_prefix, self.state.listbox.get()) + /// Get the currently selected item in listbox, spliced into the preserved + /// query context: `prefix + suggestion + suffix`. Returns the full editor + /// text and the cursor position (a `char` index) just after the inserted + /// suggestion, so mid-line completion lands the cursor before the suffix. + fn get_current_item(&self) -> (String, usize) { + let head = format!("{}{}", self.preserved_prefix, self.state.listbox.get()); + let cursor = head.chars().count(); + (format!("{head}{}", self.preserved_suffix), cursor) } /// Parsed input stream, deserialized once on first use and cached. Returns @@ -202,12 +210,13 @@ impl CompletionNavigator { } /// Handle a user input event to update the completion navigator's state accordingly. - /// Returns `Some(String)` if the event triggers a selection change that should update the query editor, + /// Returns `Some((text, cursor))` if the event triggers a selection change that should + /// update the query editor, where `cursor` is a `char` index into `text`. fn handle_user_event( &mut self, event: &Event, completion_keybinds: &CompletionKeybinds, - ) -> Option { + ) -> Option<(String, usize)> { if self.state.listbox.is_empty() { return None; } @@ -227,19 +236,24 @@ impl CompletionNavigator { None } - async fn enter(&mut self, query: &str) -> (Option, SuggestionLoadProgress) { - let ctx = crate::completion_ctx::analyze(query); - self.preserved_prefix = query[..ctx.segment_start].to_string(); - let segment = &query[ctx.segment_start..]; - - let (items, progress) = if ctx.base.is_empty() { + async fn enter( + &mut self, + query: &str, + cursor: usize, + ) -> (Option<(String, usize)>, SuggestionLoadProgress) { + let seg = crate::completion_ctx::segment_at_cursor(query, cursor); + self.preserved_prefix = seg.preserved_prefix; + self.preserved_suffix = seg.suffix; + let segment = &seg.segment; + + let (items, progress) = if seg.base.is_empty() { // Root context: use the precomputed, incrementally-loaded path store. self.shared_suggestions.collect_matches(segment).await } else { // Piped/context: evaluate the base expression and enumerate paths of // its output, so suggestions are relative to the piped value. let stream = self.input_stream(); - let items = match json::relative_paths(&ctx.base, stream) { + let items = match json::relative_paths(&seg.base, stream) { Ok(mut paths) => { paths.retain(|p| p.starts_with(segment)); paths.sort(); @@ -261,7 +275,7 @@ impl CompletionNavigator { /// Initialize a completion session with a new search result set. /// This method always resets previous session state first. - fn initialize_session_items(&mut self, mut items: Vec) -> Option { + fn initialize_session_items(&mut self, mut items: Vec) -> Option<(String, usize)> { self.clear_session_state(); if items.is_empty() { @@ -285,8 +299,9 @@ impl CompletionNavigator { } pub enum CompletionAction { - /// Triggered when the user enters the completion view with the current query. - Enter { query: String }, + /// Triggered when the user enters the completion view with the current + /// query and the cursor as a byte offset into it. + Enter { query: String, cursor: usize }, /// Triggered when the user leaves the completion view. Leave, /// Triggered on user input events within the completion view, such as navigation keys. @@ -311,10 +326,10 @@ pub fn start_completion_task( let completion_view = { let mut completion = shared_completion.write().await; match action { - CompletionAction::Enter { query } => { - let (head_item, load_progress) = completion.enter(&query).await; + CompletionAction::Enter { query, cursor } => { + let (head_item, load_progress) = completion.enter(&query, cursor).await; match head_item { - Some(head) => { + Some((text, cursor)) => { let message = if load_progress.is_complete { GuideMessage::LoadedAllSuggestions(load_progress.loaded_path_count) } else { @@ -322,7 +337,7 @@ pub fn start_completion_task( }; guide_action_tx.send(GuideAction::Show(message)).await?; query_editor_action_tx - .send(QueryEditorAction::ReplaceText(head)) + .send(QueryEditorAction::ReplaceText { text, cursor }) .await?; } None => { @@ -335,9 +350,9 @@ pub fn start_completion_task( } } CompletionAction::UserEvent(event) => { - if let Some(text) = completion.handle_user_event(&event, &completion_keybinds) { + if let Some((text, cursor)) = completion.handle_user_event(&event, &completion_keybinds) { query_editor_action_tx - .send(QueryEditorAction::ReplaceText(text)) + .send(QueryEditorAction::ReplaceText { text, cursor }) .await?; } else { shared_ctx.set_active_index(Index::QueryEditor).await; diff --git a/src/completion_ctx.rs b/src/completion_ctx.rs index dd81c89..b0f4dd1 100644 --- a/src/completion_ctx.rs +++ b/src/completion_ctx.rs @@ -64,6 +64,38 @@ pub fn analyze(query: &str) -> CompletionCtx { } } +/// Result of cursor-aware segmentation. +#[derive(Debug, PartialEq, Eq)] +pub struct Segments { + /// Expression to evaluate (empty => root). + pub base: String, + /// Text left of the cursor, up to the start of the segment being completed. + pub preserved_prefix: String, + /// The token being completed (left of the cursor, after the segment start). + pub segment: String, + /// Text right of the cursor, preserved verbatim. + pub suffix: String, +} + +/// Segment a query for completion at byte offset `cursor` (must be on a char +/// boundary — guaranteed by the char→byte conversion at the call site). +/// Analyzes only the text left of the cursor; the right side is preserved. +/// +/// Cursor-at-end (`cursor == query.len()`, the common case) makes `left == +/// query` and `suffix == ""`, so behavior is byte-identical to analyzing the +/// whole query — regression-safe. +pub fn segment_at_cursor(query: &str, cursor: usize) -> Segments { + let left = &query[..cursor]; + let suffix = query[cursor..].to_string(); + let ctx = analyze(left); // token-tree segmentation, unchanged + Segments { + base: ctx.base, + preserved_prefix: left[..ctx.segment_start].to_string(), + segment: left[ctx.segment_start..].to_string(), + suffix, + } +} + #[cfg(test)] mod tests { use super::*; @@ -114,4 +146,41 @@ mod tests { failures.len() ); } + + #[test] + fn cursor_table() { + // (query, cursor byte offset, expected base, expected segment, expected suffix) + let cases = [ + // cursor at end ≡ analyzing the whole query (regression proof) + (".foo | .ba", 10, ".foo", ".ba", ""), + // mid-line within the first token: suffix preserved verbatim + (".foo | .ba", 4, "", ".foo", " | .ba"), + // cursor mid-pipe: only the left segment is analyzed + (".a | .b | .c", 7, ".a", ".b", " | .c"), + // cursor at 0 / empty query + ("", 0, "", "", ""), + (".foo", 0, "", "", ".foo"), + // multi-byte suffix preserved (boundary-safe slice) + (".a | .b café", 7, ".a", ".b", " café"), + ]; + let mut failures = vec![]; + for (q, cursor, want_base, want_seg, want_suffix) in cases { + let seg = segment_at_cursor(q, cursor); + if seg.base != want_base || seg.segment != want_seg || seg.suffix != want_suffix { + failures.push(format!( + "query {q:?} @ {cursor}: got base={:?} seg={:?} suffix={:?}, \ + want base={:?} seg={:?} suffix={:?}", + seg.base, seg.segment, seg.suffix, want_base, want_seg, want_suffix + )); + } + } + for f in &failures { + eprintln!("FAIL: {f}"); + } + assert!( + failures.is_empty(), + "{} cursor segmentation cases failed", + failures.len() + ); + } } diff --git a/src/query_editor.rs b/src/query_editor.rs index ed8ada7..54541b8 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -20,6 +20,12 @@ use crate::{ guide::{self, GuideAction}, }; +/// Convert a `char` index into a byte offset within `s`. An index at or past +/// the end maps to `s.len()`. The result is always on a char boundary. +fn char_index_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices().nth(char_idx).map_or(s.len(), |(b, _)| b) +} + /// Editor for inputting jq query. It manages the state of the text editor /// and handles user input events to update the query text accordingly. pub struct QueryEditor { @@ -59,14 +65,36 @@ impl QueryEditor { self.state.texteditor.text_without_cursor().to_string() } + /// Current text (without cursor) and the cursor as a BYTE offset into it. + /// promkit's `position()` is a `char` index; this converts it to the byte + /// offset the completion segmentation expects (always on a char boundary). + pub fn text_and_cursor_byte(&self) -> (String, usize) { + let text = self.text(); + let byte = char_index_to_byte(&text, self.state.texteditor.position()); + (text, byte) + } + /// Create graphemes for rendering the query editor. pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { self.state.create_graphemes(width, height) } - /// Replace the current text of the query editor with the given text. - pub fn replace_text(&mut self, text: &str) { + /// Move the cursor to an absolute `char` index. + fn set_cursor(&mut self, pos: usize) { + self.state.texteditor.move_to_head(); + for _ in 0..pos { + if !self.state.texteditor.forward() { + break; + } + } + } + + /// Replace the current text and place the cursor at an absolute `char` + /// index. Used by completion to splice a suggestion mid-line and land the + /// cursor right after the inserted text. + pub fn replace_text_at(&mut self, text: &str, cursor: usize) { self.state.texteditor.replace(text); + self.set_cursor(cursor); } /// Handle a user input event to update the query editor's state accordingly. @@ -145,8 +173,9 @@ pub enum QueryEditorAction { Leave, /// Copy the current query text to clipboard. CopyQuery, - /// Replace the current query text. - ReplaceText(String), + /// Replace the current query text and place the cursor at `cursor` + /// (a `char` index into `text`). + ReplaceText { text: String, cursor: usize }, /// Handle user input events to update the query editor's state. UserEvent(Event), } @@ -179,16 +208,15 @@ pub fn start_query_editor_task( let message = guide::copy_to_clipboard_message(&editor.text()); guide_action_tx.send(GuideAction::Show(message)).await?; } - QueryEditorAction::ReplaceText(text) => { - editor.replace_text(&text); + QueryEditorAction::ReplaceText { text, cursor } => { + editor.replace_text_at(&text, cursor); } QueryEditorAction::UserEvent(event) => { if editor.handle_user_event(&event) { shared_ctx.set_active_index(Index::Completion).await; + let (query, cursor) = editor.text_and_cursor_byte(); completion_action_tx - .send(CompletionAction::Enter { - query: editor.text(), - }) + .send(CompletionAction::Enter { query, cursor }) .await?; } } @@ -215,3 +243,23 @@ pub fn start_query_editor_task( Ok(()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn char_index_to_byte_ascii_and_multibyte() { + // ASCII: char index == byte offset. + assert_eq!(char_index_to_byte("abc", 0), 0); + assert_eq!(char_index_to_byte("abc", 2), 2); + // At or past the end maps to len(). + assert_eq!(char_index_to_byte("abc", 3), 3); + assert_eq!(char_index_to_byte("abc", 99), 3); + assert_eq!(char_index_to_byte("", 0), 0); + + // Multi-byte: "café" is 'c','a','f','é' where 'é' is 2 bytes. + assert_eq!(char_index_to_byte("café", 3), 3); // start of 'é' + assert_eq!(char_index_to_byte("café", 4), 5); // end of string (3 + 2) + } +}