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/completion.rs b/src/completion.rs index 419f016..4fbebf7 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,19 @@ 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, + /// 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 { @@ -118,18 +132,41 @@ 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(), + preserved_suffix: String::new(), } } - /// Get the currently selected item in listbox. - fn get_current_item(&self) -> String { - self.state.listbox.get().to_string() + /// 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 + /// 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. @@ -173,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; } @@ -198,15 +236,46 @@ 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, + 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(&seg.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) } /// 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() { @@ -218,7 +287,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 +299,9 @@ 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 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. @@ -256,10 +326,10 @@ 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, 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 { @@ -267,12 +337,12 @@ 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 => { 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(); @@ -280,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 new file mode 100644 index 0000000..b0f4dd1 --- /dev/null +++ b/src/completion_ctx.rs @@ -0,0 +1,186 @@ +//! 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. +//! +//! 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 { + /// 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 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 { + 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, + } + } + } +} + +/// 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::*; + + 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"), + // 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 { + 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() + ); + } + + #[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/json.rs b/src/json.rs index f12a377..15fc391 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, @@ -16,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. @@ -40,7 +45,10 @@ 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,23 +58,87 @@ 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); } } 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..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 { - prefix: 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) + } +}