From 06e8d1e769eb799d522b68a8073718fda13fd755 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 11 May 2026 20:43:06 +0000 Subject: [PATCH] ADR-0022 stage 8a: non-modal keyword completion + Esc/Backspace undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's framing decision: there is no "completion mode." Tab is just an action that consumes whatever is expected at the cursor, and the existing always-on hint panel (stage 5) tells the user what's available. New `completion` module: `candidates_at_cursor(input, cursor)` returns a `Completion { replaced_range, partial_prefix, candidates }` based on the parser's expected-token set at the cursor position. Filters to bare keyword candidates only (no punctuation, no descriptive labels), narrowed by the typed prefix (case-insensitive). `LastCompletion` memo struct on `App::last_completion` carries the cycle state: inserted_range, original_text, candidates, selection_idx. Wrap-around forward/backward indices. App key handling (added before the existing matcher): - Tab → cycle forward if memo present; else insert first candidate; create / advance memo. - Shift-Tab → cycle backward if memo present; else insert last candidate (alphabetically) so the user can jump to the end without cycling through everything. - Esc / Backspace while memo alive → restore original_text in inserted_range, place cursor at the pre-Tab position, clear memo. - Any other key → clear memo, then process normally. The user's symmetry preference was load-bearing here: "insert with one keystroke, remove with one keystroke." Both Esc and Backspace honour that — multiple Tab cycles collapse into one undo. Documented inline. A single-candidate completion still creates a memo so Esc/Backspace can undo it. Multiple Tabs in a row cycle through the candidate list with wrap-around at both ends (per the user's #2). Tests: 728 passing, 0 failing, 1 ignored (705 baseline → +23: 13 completion module + 10 app integration tests covering Tab, Shift-Tab, cycling, wrap-around, Esc-undo, Backspace-undo, multi-Tab-then-Esc, memo invalidation by typing or cursor movement). Clippy clean. Stage 8b will add multi-candidate hint-panel rendering with scroll markers (`<` `>`) per the user's #2. Stage 8c will plumb in identifier completion + invalid-identifier detection. --- src/app.rs | 275 +++++++++++++++++++++++++++++++++++++++++++++ src/completion.rs | 281 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 557 insertions(+) create mode 100644 src/completion.rs diff --git a/src/app.rs b/src/app.rs index e1d6ddf..5c22183 100644 --- a/src/app.rs +++ b/src/app.rs @@ -127,6 +127,14 @@ pub struct App { /// dispatches keys to the modal instead of the input /// field. pub modal: Option, + /// Memo of the most recent Tab-completion (ADR-0022 + /// stage 8). Carries enough state to cycle to the next / + /// previous candidate on subsequent Tab / Shift-Tab + /// presses, and to undo the whole insertion in one + /// keystroke via Esc / Backspace. Cleared by *any* other + /// keystroke — no completion mode, just a transient + /// memory of "the last thing Tab did." + pub last_completion: Option, } /// Dialogs that take over keyboard input when active. @@ -236,6 +244,7 @@ impl App { project_is_temp: false, fatal_message: None, modal: None, + last_completion: None, } } @@ -483,6 +492,27 @@ impl App { if self.modal.is_some() { return self.handle_modal_key(key); } + + // ADR-0022 stage 8 — non-modal completion. Tab / + // Shift-Tab cycle; Esc / Backspace undo the whole + // last-Tab insertion in one keystroke while the memo + // is alive (per the user's symmetry preference: one + // keystroke to insert, one to remove). Any other key + // clears the memo before being processed normally. + match (key.code, key.modifiers) { + (KeyCode::Tab, _) => return self.completion_tab_forward(), + (KeyCode::BackTab, _) => return self.completion_tab_backward(), + (KeyCode::Esc, _) if self.last_completion.is_some() => { + self.undo_last_completion(); + return Vec::new(); + } + (KeyCode::Backspace, _) if self.last_completion.is_some() => { + self.undo_last_completion(); + return Vec::new(); + } + _ => self.last_completion = None, + } + match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), @@ -592,6 +622,118 @@ impl App { self.input_cursor = idx; } + /// Tab key handler — insert / cycle forward through the + /// candidates at the cursor (ADR-0022 stage 8). Three + /// shapes: + /// - memo present: cycle to next candidate, replace + /// inserted_range, advance memo.selection_idx; + /// - memo absent + multi-candidate: insert first + /// candidate (with trailing space) and create memo; + /// - memo absent + single candidate: insert it and + /// create a one-element memo so Esc/Backspace can + /// still undo; + /// - no candidates: no-op. + fn completion_tab_forward(&mut self) -> Vec { + self.cancel_history_navigation(); + if let Some(memo) = self.last_completion.take() { + let next = memo.next_idx(); + self.last_completion = Some(self.replace_inserted(memo, next)); + return Vec::new(); + } + if let Some(memo) = self.start_completion(0) { + self.last_completion = Some(memo); + } + Vec::new() + } + + /// Shift-Tab key handler — symmetric to + /// `completion_tab_forward` but cycles backward, and + /// starts from the last candidate (alphabetically) if no + /// memo exists. Per the user's #2: wrap-around at both + /// ends. + fn completion_tab_backward(&mut self) -> Vec { + self.cancel_history_navigation(); + if let Some(memo) = self.last_completion.take() { + let prev = memo.prev_idx(); + self.last_completion = Some(self.replace_inserted(memo, prev)); + return Vec::new(); + } + if let Some(memo) = self.start_completion_last() { + self.last_completion = Some(memo); + } + Vec::new() + } + + /// Esc / Backspace handler while a completion memo is + /// alive — restore the original text in + /// `inserted_range` and place the cursor where the user + /// was when they hit Tab. The memo is cleared. + fn undo_last_completion(&mut self) { + let Some(memo) = self.last_completion.take() else { + return; + }; + let (start, end) = memo.inserted_range; + self.input.replace_range(start..end, &memo.original_text); + self.input_cursor = start + memo.original_text.len(); + } + + /// Compute the candidates at the cursor and insert the + /// candidate at index `start_idx` (with a trailing space). + /// Returns the freshly-built memo. `None` if there are no + /// candidates to insert. + fn start_completion(&mut self, start_idx: usize) -> Option { + let cursor = self.input_cursor.min(self.input.len()); + let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?; + let idx = start_idx % comp.candidates.len(); + let inserted = format!("{} ", comp.candidates[idx]); + let original_text = + self.input[comp.replaced_range.0..comp.replaced_range.1].to_string(); + self.input + .replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted); + let new_end = comp.replaced_range.0 + inserted.len(); + self.input_cursor = new_end; + Some(crate::completion::LastCompletion { + inserted_range: (comp.replaced_range.0, new_end), + original_text, + candidates: comp.candidates, + selection_idx: idx, + }) + } + + /// Same as `start_completion` but starts from the last + /// candidate alphabetically (for Shift-Tab from a fresh + /// state). + fn start_completion_last(&mut self) -> Option { + // We need to know how many candidates there will be + // before we can pick "last". Compute the completion + // first, then call start_completion with that index. + let cursor = self.input_cursor.min(self.input.len()); + let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?; + let last = comp.candidates.len() - 1; + // Fall through to the same path so the inserted text + // and memo construction stay in one place. + self.start_completion(last) + } + + /// Replace the inserted text with `candidates[idx]` and + /// return an updated memo. Used by Tab/Shift-Tab cycling. + fn replace_inserted( + &mut self, + memo: crate::completion::LastCompletion, + idx: usize, + ) -> crate::completion::LastCompletion { + let new_inserted = format!("{} ", memo.candidates[idx]); + let (start, end) = memo.inserted_range; + self.input.replace_range(start..end, &new_inserted); + let new_end = start + new_inserted.len(); + self.input_cursor = new_end; + crate::completion::LastCompletion { + inserted_range: (start, new_end), + selection_idx: idx, + ..memo + } + } + fn delete_at_cursor(&mut self) { if self.input_cursor >= self.input.len() { return; @@ -1607,6 +1749,139 @@ mod tests { .join("\n") } + // ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ---- + + #[test] + fn tab_inserts_unique_keyword_with_trailing_space() { + let mut app = App::new(); + type_str(&mut app, "cre"); + let actions = app.update(key(KeyCode::Tab)); + assert!(actions.is_empty()); + assert_eq!(app.input, "create "); + assert_eq!(app.input_cursor, 7); + // Memo is alive even for a single-candidate completion + // so Esc/Backspace can still undo. + assert!(app.last_completion.is_some()); + } + + #[test] + fn tab_at_word_boundary_inserts_next_expected_keyword() { + let mut app = App::new(); + type_str(&mut app, "create "); + let actions = app.update(key(KeyCode::Tab)); + assert!(actions.is_empty()); + assert_eq!(app.input, "create table "); + } + + #[test] + fn tab_with_no_candidates_is_a_noop() { + // After `create table T with pk` the parser succeeds — + // no candidates, Tab does nothing. + let mut app = App::new(); + type_str(&mut app, "create table T with pk"); + let len = app.input.len(); + app.update(key(KeyCode::Tab)); + assert_eq!(app.input.len(), len); + assert!(app.last_completion.is_none()); + } + + #[test] + fn tab_cycles_forward_through_multi_candidate_set() { + // After `show ` two candidates: data, table. + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); + assert_eq!(app.input, "show data "); + app.update(key(KeyCode::Tab)); + assert_eq!(app.input, "show table "); + // Wrap-around per the user's #2. + app.update(key(KeyCode::Tab)); + assert_eq!(app.input, "show data "); + } + + #[test] + fn shift_tab_cycles_backward_starting_from_last() { + // Fresh Shift-Tab on multi-candidate position starts + // at the last candidate alphabetically. + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::BackTab)); + assert_eq!(app.input, "show table "); + app.update(key(KeyCode::BackTab)); + assert_eq!(app.input, "show data "); + // Wrap. + app.update(key(KeyCode::BackTab)); + assert_eq!(app.input, "show table "); + } + + #[test] + fn esc_after_tab_restores_original_in_one_keystroke() { + let mut app = App::new(); + type_str(&mut app, "cre"); + app.update(key(KeyCode::Tab)); + assert_eq!(app.input, "create "); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, "cre"); + assert_eq!(app.input_cursor, 3); + assert!(app.last_completion.is_none()); + } + + #[test] + fn backspace_after_tab_restores_original_in_one_keystroke() { + // Symmetry: one keystroke to insert, one to remove. + let mut app = App::new(); + type_str(&mut app, "cre"); + app.update(key(KeyCode::Tab)); + assert_eq!(app.input, "create "); + app.update(key(KeyCode::Backspace)); + assert_eq!(app.input, "cre"); + assert_eq!(app.input_cursor, 3); + assert!(app.last_completion.is_none()); + } + + #[test] + fn esc_after_multiple_tabs_restores_original_state_not_previous_cycle() { + // Tab Tab Tab cycled through three candidates; Esc + // restores the pre-completion state (the original + // partial prefix), not the previous cycle. + let mut app = App::new(); + type_str(&mut app, "drop "); + app.update(key(KeyCode::Tab)); // column + app.update(key(KeyCode::Tab)); // relationship + app.update(key(KeyCode::Tab)); // table + assert_eq!(app.input, "drop table "); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, "drop "); + } + + #[test] + fn typing_a_letter_clears_the_completion_memo() { + let mut app = App::new(); + type_str(&mut app, "cre"); + app.update(key(KeyCode::Tab)); + assert!(app.last_completion.is_some()); + // Typing any non-Tab key clears the memo. The + // inserted text stays in the buffer. + type_str(&mut app, "x"); + assert_eq!(app.input, "create x"); + assert!(app.last_completion.is_none()); + // Backspace now does its normal job — delete one char. + app.update(key(KeyCode::Backspace)); + assert_eq!(app.input, "create "); + } + + #[test] + fn cursor_movement_clears_the_completion_memo() { + let mut app = App::new(); + type_str(&mut app, "cre"); + app.update(key(KeyCode::Tab)); + // Per the user's #3 — cursor movement clears the + // memo. After this, Esc / Backspace behave normally + // and don't trigger the whole-span undo. + app.update(key(KeyCode::Home)); + assert!(app.last_completion.is_none()); + } + fn sample_description(name: &str) -> TableDescription { TableDescription { name: name.to_string(), diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..530753d --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,281 @@ +//! Keyword + identifier completion for ambient typing +//! assistance (ADR-0022 stage 8). +//! +//! This stage 1 cut covers keyword completion only. Identifier +//! completion (schema-aware) lands in a follow-on substage +//! that builds on this scaffolding. +//! +//! Concept: `candidates_at_cursor(input, cursor)` returns a +//! `Completion` describing what would happen if the user +//! pressed Tab right now — the byte range to replace, the +//! partial prefix already typed, and the list of fitting +//! candidates. Empty / no-candidate cases return `None`. +//! +//! The cycling memo (`LastCompletion` on `App`) lives in +//! `app.rs`; this module owns the candidate computation. + +use crate::dsl::keyword::Keyword; +use crate::dsl::usage; +use crate::dsl::{ParseError, parse_command}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Completion { + /// Byte range in `input` to be replaced when a candidate + /// is accepted. Equal `(cursor, cursor)` when the user is + /// at a token boundary (no partial prefix). + pub replaced_range: (usize, usize), + /// Partial prefix the user has typed at the cursor. Empty + /// when the cursor is at a token boundary. + pub partial_prefix: String, + /// Fitting candidates, alphabetised, deduplicated. Each + /// candidate is the bare keyword text (no backticks). When + /// inserted, the renderer adds a trailing space. + pub candidates: Vec, +} + +/// Compute what would happen if the user pressed Tab right +/// now. `None` means there is nothing to complete (no +/// candidates fit / cursor at end of complete input). +#[must_use] +pub fn candidates_at_cursor(input: &str, cursor: usize) -> Option { + let cursor = cursor.min(input.len()); + + // Walk backward from the cursor over identifier-shaped + // characters to find the partial prefix the user is mid-typing. + let bytes = input.as_bytes(); + let mut start = cursor; + while start > 0 { + let prev = bytes[start - 1]; + if prev.is_ascii_alphanumeric() || prev == b'_' { + start -= 1; + } else { + break; + } + } + let partial_prefix = input[start..cursor].to_string(); + let leading = &input[..start]; + + let expected = expected_set(leading); + if expected.is_empty() { + return None; + } + + // Filter to bare keyword candidates only — the parser may + // surface descriptive labels ("identifier", "string literal", + // "where clause or --all-rows") or punctuation; neither is + // useful as Tab-insertable text (ADR-0022 §10). Then + // narrow by the typed partial prefix (case-insensitive). + let lowered_prefix = partial_prefix.to_lowercase(); + let mut candidates: Vec = expected + .iter() + .filter_map(|item| strip_backticks(item)) + .filter_map(|name| Keyword::from_word(name).map(|_| name.to_string())) + .filter(|name| name.to_lowercase().starts_with(&lowered_prefix)) + .collect(); + candidates.sort(); + candidates.dedup(); + + if candidates.is_empty() { + return None; + } + + Some(Completion { + replaced_range: (start, cursor), + partial_prefix, + candidates, + }) +} + +fn strip_backticks(s: &str) -> Option<&str> { + s.strip_prefix('`').and_then(|s| s.strip_suffix('`')) +} + +/// The expected-token set at the end of `leading`. Empty +/// `leading` (whitespace only) yields every command-entry +/// keyword — there's no parser failure to drive this from, so +/// we synthesise it from the usage registry. +fn expected_set(leading: &str) -> Vec { + if leading.trim().is_empty() { + return usage::entry_keywords_alphabetised() + .into_iter() + .map(|kw| format!("`{}`", kw.as_str())) + .collect(); + } + match parse_command(leading) { + Ok(_) => Vec::new(), + Err(ParseError::Empty) => Vec::new(), + Err(ParseError::Invalid { expected, .. }) => expected, + } +} + +/// Snapshot of a freshly-inserted completion. The memo lives +/// on `App::last_completion` until any non-Tab/non-Shift-Tab +/// keystroke clears it (or Esc/Backspace consumes it). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LastCompletion { + /// Byte range in the input currently occupied by the + /// inserted candidate (including the trailing space). + pub inserted_range: (usize, usize), + /// The text that was at `inserted_range` *before* any + /// completion was applied — restored by Esc / Backspace. + pub original_text: String, + /// Cycle list, fixed at memo-creation time. + pub candidates: Vec, + /// Which `candidates[i]` is currently visible. + pub selection_idx: usize, +} + +impl LastCompletion { + /// Wrap-around forward step. + #[must_use] + pub const fn next_idx(&self) -> usize { + (self.selection_idx + 1) % self.candidates.len() + } + + /// Wrap-around backward step. + #[must_use] + pub const fn prev_idx(&self) -> usize { + (self.selection_idx + self.candidates.len() - 1) % self.candidates.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn cands(input: &str, cursor: usize) -> Vec { + candidates_at_cursor(input, cursor) + .map_or_else(Vec::new, |c| c.candidates) + } + + #[test] + fn empty_input_offers_all_command_entry_keywords() { + let cs = cands("", 0); + // Ten command-entry keywords. + assert!(cs.contains(&"create".to_string())); + assert!(cs.contains(&"drop".to_string())); + assert!(cs.contains(&"add".to_string())); + assert!(cs.contains(&"insert".to_string())); + assert!(cs.contains(&"update".to_string())); + assert!(cs.contains(&"delete".to_string())); + assert!(cs.contains(&"show".to_string())); + assert!(cs.contains(&"rename".to_string())); + assert!(cs.contains(&"change".to_string())); + assert!(cs.contains(&"replay".to_string())); + } + + #[test] + fn partial_keyword_narrows_to_matching_entries() { + // Typed `cr` — only `create` starts with that. + let cs = cands("cr", 2); + assert_eq!(cs, vec!["create".to_string()]); + } + + #[test] + fn partial_keyword_is_case_insensitive() { + let cs = cands("CR", 2); + assert_eq!(cs, vec!["create".to_string()]); + } + + #[test] + fn at_token_boundary_offers_next_expected_keyword() { + // After `create ` the parser expects `table`. + let cs = cands("create ", 7); + assert_eq!(cs, vec!["table".to_string()]); + } + + #[test] + fn multi_candidate_position_offers_all_options() { + // After `add ` the parser expects `1` (for 1:n) or + // `column`. Only `column` is a Keyword variant — `1` + // is a number-literal pattern. Tab on this position + // offers `column` only. + let cs = cands("add ", 4); + assert_eq!(cs, vec!["column".to_string()]); + } + + #[test] + fn show_offers_data_and_table_alphabetised() { + let cs = cands("show ", 5); + assert_eq!(cs, vec!["data".to_string(), "table".to_string()]); + } + + #[test] + fn drop_offers_three_alternatives_alphabetised() { + let cs = cands("drop ", 5); + assert_eq!( + cs, + vec![ + "column".to_string(), + "relationship".to_string(), + "table".to_string(), + ], + ); + } + + #[test] + fn complete_command_offers_no_candidates() { + // `create table T with pk` is a complete command — + // no candidates to offer. + let input = "create table T with pk"; + let cs = cands(input, input.len()); + assert!(cs.is_empty(), "got {cs:?}"); + } + + #[test] + fn punctuation_expected_does_not_produce_candidates() { + // After `add column to table T` parser expects `:`. + // Tab should NOT offer the colon character. + let input = "add column to table T"; + let cs = cands(input, input.len()); + assert!(cs.is_empty(), "punctuation should not be offered: {cs:?}"); + } + + #[test] + fn cursor_mid_keyword_replaces_only_the_partial_prefix() { + // Typed `cre`, cursor at byte 3. The completion + // replaces (0,3) with `create`. + let comp = candidates_at_cursor("cre", 3).expect("some completion"); + assert_eq!(comp.replaced_range, (0, 3)); + assert_eq!(comp.partial_prefix, "cre"); + assert_eq!(comp.candidates, vec!["create".to_string()]); + } + + #[test] + fn cursor_at_word_boundary_has_empty_partial_prefix() { + let comp = candidates_at_cursor("create ", 7).expect("some completion"); + assert_eq!(comp.replaced_range, (7, 7)); + assert_eq!(comp.partial_prefix, ""); + } + + #[test] + fn last_completion_next_idx_wraps_around() { + let mut memo = LastCompletion { + inserted_range: (0, 0), + original_text: String::new(), + candidates: vec!["a".into(), "b".into(), "c".into()], + selection_idx: 0, + }; + assert_eq!(memo.next_idx(), 1); + memo.selection_idx = 1; + assert_eq!(memo.next_idx(), 2); + memo.selection_idx = 2; + assert_eq!(memo.next_idx(), 0); + } + + #[test] + fn last_completion_prev_idx_wraps_around() { + let mut memo = LastCompletion { + inserted_range: (0, 0), + original_text: String::new(), + candidates: vec!["a".into(), "b".into(), "c".into()], + selection_idx: 0, + }; + assert_eq!(memo.prev_idx(), 2); + memo.selection_idx = 2; + assert_eq!(memo.prev_idx(), 1); + memo.selection_idx = 1; + assert_eq!(memo.prev_idx(), 0); + } +} diff --git a/src/lib.rs b/src/lib.rs index 58ff5c3..fa076fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod action; pub mod app; pub mod archive; pub mod cli; +pub mod completion; pub mod db; pub mod dsl; pub mod event;