ADR-0022 stage 8a: non-modal keyword completion + Esc/Backspace undo
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.
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Completion> {
|
||||
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<String> = 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<String> {
|
||||
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<String>,
|
||||
/// 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user