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:
claude@clouddev1
2026-05-11 20:43:06 +00:00
parent aea3224da2
commit 06e8d1e769
3 changed files with 557 additions and 0 deletions
+281
View File
@@ -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);
}
}