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:
+275
@@ -127,6 +127,14 @@ pub struct App {
|
|||||||
/// dispatches keys to the modal instead of the input
|
/// dispatches keys to the modal instead of the input
|
||||||
/// field.
|
/// field.
|
||||||
pub modal: Option<Modal>,
|
pub modal: Option<Modal>,
|
||||||
|
/// 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<crate::completion::LastCompletion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dialogs that take over keyboard input when active.
|
/// Dialogs that take over keyboard input when active.
|
||||||
@@ -236,6 +244,7 @@ impl App {
|
|||||||
project_is_temp: false,
|
project_is_temp: false,
|
||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
modal: None,
|
modal: None,
|
||||||
|
last_completion: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +492,27 @@ impl App {
|
|||||||
if self.modal.is_some() {
|
if self.modal.is_some() {
|
||||||
return self.handle_modal_key(key);
|
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) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
||||||
(KeyCode::Enter, _) => self.submit(),
|
(KeyCode::Enter, _) => self.submit(),
|
||||||
@@ -592,6 +622,118 @@ impl App {
|
|||||||
self.input_cursor = idx;
|
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<Action> {
|
||||||
|
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<Action> {
|
||||||
|
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<crate::completion::LastCompletion> {
|
||||||
|
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<crate::completion::LastCompletion> {
|
||||||
|
// 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) {
|
fn delete_at_cursor(&mut self) {
|
||||||
if self.input_cursor >= self.input.len() {
|
if self.input_cursor >= self.input.len() {
|
||||||
return;
|
return;
|
||||||
@@ -1607,6 +1749,139 @@ mod tests {
|
|||||||
.join("\n")
|
.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 {
|
fn sample_description(name: &str) -> TableDescription {
|
||||||
TableDescription {
|
TableDescription {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod action;
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod completion;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod dsl;
|
pub mod dsl;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
|||||||
Reference in New Issue
Block a user