8214e4136a
Per the user's #5: "if our candidate selection works correctly, then entering a character that removes all matches is the same as entering an invalid token." Closes the loop between schema cache (8c/8d) and live error feedback (4). New `completion::invalid_ident_at_cursor(input, cursor, cache)` returns `Some(InvalidIdent { range, found, slot })` when: - the cursor is on a partial identifier-shaped token; - the parser's expected-set at the start of that token contains a known-set IdentSlot (TableName / Column / RelationshipName); - no schema entry across those slots prefix-matches the typed text. `render_input_runs` extended to take a `&SchemaCache` and overlay the invalid-identifier range with `tok_error` — same visual treatment as the parse-error overlay (4), unified red signal regardless of which detector fires. `ambient_hint` extended to surface `hint.ambient_invalid_ident` when invalid_ident_at_cursor returns Some — wording "no such {kind}: `{found}`" mirrors ADR-0019's engine-error voice for consistency. Catalog + KEYS_AND_PLACEHOLDERS declaration added; validator passes. Render priority: candidates win over invalid-ident (if any schema match exists for the partial prefix, the state is "in-progress completion" not "invalid"). Falls through to the existing parse-error/incomplete/Valid framings otherwise. NewName slots are filtered out at the source — typing into a "user invents this name" position is never invalid (per `IdentSlot::completes_from_schema`). Tests: 744 passing, 0 failing, 1 ignored (738 baseline → +6: 5 invalid_ident_at_cursor cases covering unknown-prefix-fires, prefix-match-doesn't-fire, NewName-immune, no-cursor-token, keyword-slot-immune; plus 1 ambient_hint integration test). Clippy clean. This closes ADR-0022. Stages 1-8e together deliver the ambient-typing-assistance feature: token highlighting, error overlay, hint panel ambient, hint panel multi- candidate display with scroll markers, Tab/Shift-Tab cycling with one-keystroke Esc/Backspace undo, schema-aware identifier completion, and invalid-identifier live feedback. Total stage-8 footprint: 5 commits, ~1600 lines.
709 lines
25 KiB
Rust
709 lines
25 KiB
Rust
//! Render-side helpers for the input panel
|
|
//! (ADR-0022 stage 2 — token-class colouring of the live
|
|
//! input field with cursor injection).
|
|
//!
|
|
//! The functions here are pure: given an input string, a
|
|
//! cursor byte position, and a theme, they return a sequence
|
|
//! of `StyledRun`s describing each contiguous span with its
|
|
//! ratatui style. `ui::render_input_panel` converts these to
|
|
//! `Span<'_>`s at render time.
|
|
//!
|
|
//! Cursor handling:
|
|
//! - Cursor inside a token splits that token's run into
|
|
//! before/under/after, with `under` carrying the token's
|
|
//! colour plus `Modifier::REVERSED`.
|
|
//! - Cursor on a whitespace gap between tokens splits the
|
|
//! gap the same way.
|
|
//! - Cursor at end-of-input is represented as a trailing
|
|
//! run with empty byte range; the renderer treats that as
|
|
//! "inverted space".
|
|
//!
|
|
//! Per ADR-0022 §2/§3, this is the silent always-on layer.
|
|
//! The error overlay (stage 4) and hint panel (stage 5)
|
|
//! compose with these runs without fighting them.
|
|
|
|
use ratatui::style::{Modifier, Style};
|
|
|
|
use crate::dsl::lexer::lex;
|
|
use crate::dsl::{ParseError, parse_command};
|
|
use crate::theme::Theme;
|
|
|
|
/// A run of text with its byte range in the source and the
|
|
/// ratatui style it should render with. The text itself is
|
|
/// not stored — callers slice `source[byte_range.0..byte_range.1]`.
|
|
///
|
|
/// An empty byte range (`(n, n)`) represents the end-of-input
|
|
/// cursor and is rendered as an inverted space.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct StyledRun {
|
|
pub byte_range: (usize, usize),
|
|
pub style: Style,
|
|
}
|
|
|
|
impl StyledRun {
|
|
/// The text this run covers in `source`. Empty for the
|
|
/// end-of-input cursor sentinel.
|
|
#[must_use]
|
|
pub fn text<'a>(&self, source: &'a str) -> &'a str {
|
|
&source[self.byte_range.0..self.byte_range.1]
|
|
}
|
|
}
|
|
|
|
/// Build the run sequence for the input panel.
|
|
///
|
|
/// Lexes `input`, assigns each token its `theme.token_color`,
|
|
/// applies the parse-error overlay if the input is in the
|
|
/// definite-error state (ADR-0022 §1, §4), applies the
|
|
/// invalid-identifier overlay if the cursor is in a known-set
|
|
/// slot with no schema match (stage 8e), preserves whitespace
|
|
/// gaps as `theme.fg` runs, then injects the cursor at
|
|
/// `cursor_byte` (clamped to `input.len()`).
|
|
#[must_use]
|
|
pub fn render_input_runs(
|
|
input: &str,
|
|
cursor_byte: usize,
|
|
theme: &Theme,
|
|
cache: &crate::completion::SchemaCache,
|
|
) -> Vec<StyledRun> {
|
|
let mut runs = lex_to_runs(input, theme);
|
|
if let InputState::DefiniteErrorAt(pos) = classify_input(input) {
|
|
overlay_error(&mut runs, pos, theme);
|
|
}
|
|
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor_byte, cache) {
|
|
overlay_error(&mut runs, inv.range.0, theme);
|
|
}
|
|
inject_cursor(&mut runs, input, cursor_byte, theme);
|
|
runs
|
|
}
|
|
|
|
/// One of three mid-typing classifications (ADR-0022 §1).
|
|
///
|
|
/// Distinguishes "the user isn't done yet" from "this token
|
|
/// can never fit". Drives error overlay (this stage) and the
|
|
/// hint panel ambient mode (stage 5).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum InputState {
|
|
/// No tokens at all (empty / whitespace-only input).
|
|
Empty,
|
|
/// Parses to a complete `Command`. The user can submit.
|
|
Valid,
|
|
/// Parse failed because more input was expected — every
|
|
/// consumed token fits a known command, just not all of
|
|
/// it is here yet.
|
|
IncompleteAtEof,
|
|
/// Parse failed at a token strictly inside the input —
|
|
/// no continuation can recover. The byte offset is the
|
|
/// failing token's start.
|
|
DefiniteErrorAt(usize),
|
|
}
|
|
|
|
/// Classify `input` into one of the three mid-typing states.
|
|
/// Cheap (lex + parse) per ADR-0022 §13.
|
|
#[must_use]
|
|
pub fn classify_input(input: &str) -> InputState {
|
|
if input.trim().is_empty() {
|
|
return InputState::Empty;
|
|
}
|
|
match parse_command(input) {
|
|
Ok(_) => InputState::Valid,
|
|
Err(ParseError::Empty) => InputState::Empty,
|
|
Err(err @ ParseError::Invalid { position, .. }) => {
|
|
// `at_eof` is the parser's own classification: true
|
|
// when more input would (potentially) help, false
|
|
// when a specific token is in the wrong place.
|
|
// Custom-error inputs (try_map failures) currently
|
|
// map to `at_eof = true` — see the field docstring
|
|
// on `ParseError::Invalid::at_eof`.
|
|
if err.at_eof() {
|
|
InputState::IncompleteAtEof
|
|
} else {
|
|
InputState::DefiniteErrorAt(position)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ambient hint-panel content for the user's current input
|
|
/// (ADR-0022 §6, stage 8b). The renderer dispatches on the
|
|
/// returned variant.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum AmbientHint {
|
|
/// Single-line prose hint — used for "submit with Enter",
|
|
/// IncompleteAtEof with no keyword candidates (i.e. an
|
|
/// identifier or punctuation slot), and definite-error
|
|
/// states with optional usage template.
|
|
Prose(String),
|
|
/// Multi-candidate (or single-candidate keyword)
|
|
/// completion at the cursor. Stage 8b renders these as
|
|
/// styled spans with the selected item highlighted (if
|
|
/// any) and `<` / `>` scroll markers when items overflow
|
|
/// the panel width.
|
|
Candidates {
|
|
items: Vec<String>,
|
|
/// Index into `items` of the currently-inserted Tab
|
|
/// candidate (per the live `LastCompletion` memo), or
|
|
/// `None` if the user hasn't pressed Tab yet.
|
|
selected: Option<usize>,
|
|
},
|
|
}
|
|
|
|
/// Compute the ambient hint for the input panel
|
|
/// (ADR-0022 §6).
|
|
///
|
|
/// Returns `None` for empty input — caller falls back to
|
|
/// `panel.hint_empty`.
|
|
#[must_use]
|
|
pub fn ambient_hint(
|
|
input: &str,
|
|
cursor: usize,
|
|
memo: Option<&crate::completion::LastCompletion>,
|
|
cache: &crate::completion::SchemaCache,
|
|
) -> Option<AmbientHint> {
|
|
if input.trim().is_empty() {
|
|
return None;
|
|
}
|
|
// First check for candidates at the cursor (keywords +
|
|
// schema identifiers). When any exist the user can Tab to
|
|
// insert one, and the panel surfaces them directly — this
|
|
// wins over the prose IncompleteAtEof framing because the
|
|
// candidate list is more actionable.
|
|
if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) {
|
|
let selected = memo.map(|m| m.selection_idx);
|
|
return Some(AmbientHint::Candidates {
|
|
items: comp.candidates,
|
|
selected,
|
|
});
|
|
}
|
|
// Invalid identifier: cursor sits in a known-set slot but
|
|
// the typed prefix matches nothing in the schema. (Stage
|
|
// 8e / the user's #5.)
|
|
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor, cache) {
|
|
let kind = match inv.slot {
|
|
crate::dsl::ident_slot::IdentSlot::TableName => "table",
|
|
crate::dsl::ident_slot::IdentSlot::Column => "column",
|
|
crate::dsl::ident_slot::IdentSlot::RelationshipName => "relationship",
|
|
// `NewName` is filtered out by `invalid_ident_at_cursor`
|
|
// (it only fires for known-set slots), so this arm
|
|
// is unreachable in practice; render a neutral
|
|
// fallback rather than panic.
|
|
crate::dsl::ident_slot::IdentSlot::NewName => "identifier",
|
|
};
|
|
return Some(AmbientHint::Prose(crate::t!(
|
|
"hint.ambient_invalid_ident",
|
|
kind = kind,
|
|
found = inv.found,
|
|
)));
|
|
}
|
|
// Otherwise fall back to the prose framings from stage 5.
|
|
match parse_command(input) {
|
|
Ok(_) => Some(AmbientHint::Prose(crate::t!("hint.ambient_complete"))),
|
|
Err(ParseError::Empty) => None,
|
|
Err(ParseError::Invalid {
|
|
message,
|
|
position,
|
|
at_eof,
|
|
expected,
|
|
}) => {
|
|
if at_eof {
|
|
if expected.is_empty() {
|
|
Some(AmbientHint::Prose(message))
|
|
} else {
|
|
let joined = oxford_or(&expected);
|
|
Some(AmbientHint::Prose(crate::t!(
|
|
"hint.ambient_expected",
|
|
expected = joined
|
|
)))
|
|
}
|
|
} else {
|
|
let tokens = lex(input);
|
|
let usage = crate::dsl::usage::matched_entry(&tokens, position)
|
|
.and_then(|(_, keys)| keys.first().copied())
|
|
.map(|key| crate::friendly::translate(key, &[]));
|
|
Some(AmbientHint::Prose(match usage {
|
|
Some(u) => crate::t!(
|
|
"hint.ambient_error_with_usage",
|
|
message = message,
|
|
usage = u,
|
|
),
|
|
None => message,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "A, B, or C" / "A or B" / "A". Local copy because the
|
|
/// parser's identical helper is private.
|
|
fn oxford_or(items: &[String]) -> String {
|
|
match items {
|
|
[] => String::new(),
|
|
[a] => a.clone(),
|
|
[a, b] => format!("{a} or {b}"),
|
|
rest => {
|
|
let (last, head) = rest.split_last().expect("len >= 3");
|
|
format!("{}, or {last}", head.join(", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn overlay_error(runs: &mut [StyledRun], error_byte: usize, theme: &Theme) {
|
|
// Failing tokens have their byte_range starting exactly at
|
|
// `error_byte`. Override the fg colour while preserving any
|
|
// other style bits the base run carried.
|
|
if let Some(run) = runs.iter_mut().find(|r| r.byte_range.0 == error_byte) {
|
|
run.style = run.style.fg(theme.tok_error);
|
|
}
|
|
// If no run starts at error_byte, the failure is past the
|
|
// last token (an EOF failure misclassified as definite —
|
|
// shouldn't happen given classify_input's contract). No-op.
|
|
}
|
|
|
|
/// Cursor-less variant: tokenises `input` into styled runs
|
|
/// covering the full byte range, with no inverted cursor.
|
|
/// Used by the echo-line renderer (ADR-0022 §5) where there's
|
|
/// no cursor to show.
|
|
#[must_use]
|
|
pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
|
|
base_runs(input, theme)
|
|
}
|
|
|
|
fn base_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
|
|
let tokens = lex(input);
|
|
let mut runs = Vec::with_capacity(tokens.len() * 2);
|
|
let mut pos = 0;
|
|
for tok in tokens {
|
|
let (start, end) = tok.span;
|
|
if pos < start {
|
|
// Whitespace gap before this token.
|
|
runs.push(StyledRun {
|
|
byte_range: (pos, start),
|
|
style: Style::default().fg(theme.fg),
|
|
});
|
|
}
|
|
runs.push(StyledRun {
|
|
byte_range: (start, end),
|
|
style: Style::default().fg(theme.token_color(&tok.kind)),
|
|
});
|
|
pos = end;
|
|
}
|
|
if pos < input.len() {
|
|
runs.push(StyledRun {
|
|
byte_range: (pos, input.len()),
|
|
style: Style::default().fg(theme.fg),
|
|
});
|
|
}
|
|
runs
|
|
}
|
|
|
|
fn inject_cursor(
|
|
runs: &mut Vec<StyledRun>,
|
|
input: &str,
|
|
cursor_byte: usize,
|
|
theme: &Theme,
|
|
) {
|
|
let cursor_byte = cursor_byte.min(input.len());
|
|
|
|
// End-of-input cursor: append the empty-range sentinel.
|
|
if cursor_byte == input.len() {
|
|
runs.push(StyledRun {
|
|
byte_range: (input.len(), input.len()),
|
|
style: Style::default()
|
|
.fg(theme.fg)
|
|
.add_modifier(Modifier::REVERSED),
|
|
});
|
|
return;
|
|
}
|
|
|
|
let idx = runs
|
|
.iter()
|
|
.position(|r| r.byte_range.0 <= cursor_byte && cursor_byte < r.byte_range.1)
|
|
.expect("cursor_byte < input.len() ⇒ some run contains it");
|
|
let target = runs[idx].clone();
|
|
let (start, end) = target.byte_range;
|
|
|
|
// Walk to the next char boundary so a multi-byte UTF-8
|
|
// codepoint is treated as a single visual unit at the
|
|
// cursor.
|
|
let mut char_end = cursor_byte + 1;
|
|
while char_end < input.len() && !input.is_char_boundary(char_end) {
|
|
char_end += 1;
|
|
}
|
|
|
|
let mut replacement: Vec<StyledRun> = Vec::with_capacity(3);
|
|
if start < cursor_byte {
|
|
replacement.push(StyledRun {
|
|
byte_range: (start, cursor_byte),
|
|
style: target.style,
|
|
});
|
|
}
|
|
replacement.push(StyledRun {
|
|
byte_range: (cursor_byte, char_end),
|
|
style: target.style.add_modifier(Modifier::REVERSED),
|
|
});
|
|
if char_end < end {
|
|
replacement.push(StyledRun {
|
|
byte_range: (char_end, end),
|
|
style: target.style,
|
|
});
|
|
}
|
|
|
|
runs.splice(idx..=idx, replacement);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn dark() -> Theme {
|
|
Theme::dark()
|
|
}
|
|
|
|
fn reversed(r: &StyledRun) -> bool {
|
|
r.style.add_modifier.contains(Modifier::REVERSED)
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_renders_only_the_end_of_input_cursor() {
|
|
let runs = render_input_runs("", 0, &dark(), &empty_cache());
|
|
assert_eq!(runs.len(), 1);
|
|
assert_eq!(runs[0].byte_range, (0, 0));
|
|
assert!(reversed(&runs[0]));
|
|
}
|
|
|
|
#[test]
|
|
fn keyword_token_takes_keyword_colour() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create", 6, &theme, &empty_cache());
|
|
// Token + end-of-input cursor.
|
|
assert_eq!(runs.len(), 2);
|
|
assert_eq!(runs[0].byte_range, (0, 6));
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_keyword));
|
|
assert!(!reversed(&runs[0]));
|
|
assert!(reversed(&runs[1]));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_inside_token_splits_into_three_runs_keeping_colour() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create", 3, &theme, &empty_cache());
|
|
assert_eq!(runs.len(), 3);
|
|
assert_eq!(runs[0].byte_range, (0, 3));
|
|
assert_eq!(runs[1].byte_range, (3, 4));
|
|
assert_eq!(runs[2].byte_range, (4, 6));
|
|
// All three keep the keyword colour.
|
|
for r in &runs {
|
|
assert_eq!(r.style.fg, Some(theme.tok_keyword));
|
|
}
|
|
assert!(!reversed(&runs[0]));
|
|
assert!(reversed(&runs[1]));
|
|
assert!(!reversed(&runs[2]));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_on_whitespace_inverts_a_single_space() {
|
|
let theme = dark();
|
|
// "create table" has whitespace at byte 6.
|
|
let runs = render_input_runs("create table", 6, &theme, &empty_cache());
|
|
// base: keyword, ws(6,7), keyword. After cursor injection
|
|
// at the start of ws: under=(6,7) REVERSED. The
|
|
// before/after slices are empty so we get 3 runs total.
|
|
assert_eq!(runs.len(), 3);
|
|
let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect();
|
|
assert_eq!(r_under.len(), 1);
|
|
assert_eq!(r_under[0].byte_range, (6, 7));
|
|
assert_eq!(r_under[0].style.fg, Some(theme.fg));
|
|
}
|
|
|
|
#[test]
|
|
fn lex_error_token_renders_in_error_colour() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("$", 1, &theme, &empty_cache());
|
|
// Error token (0,1), then end-of-input cursor (1,1).
|
|
assert_eq!(runs.len(), 2);
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_error));
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_between_tokens_takes_default_fg() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create table", 12, &theme, &empty_cache());
|
|
// base: keyword(0,6), ws(6,7), keyword(7,12). Plus
|
|
// end-of-input cursor (12,12) = 4 runs.
|
|
assert_eq!(runs.len(), 4);
|
|
assert_eq!(runs[1].byte_range, (6, 7));
|
|
assert_eq!(runs[1].style.fg, Some(theme.fg));
|
|
assert_eq!(runs[3].byte_range, (12, 12));
|
|
assert!(reversed(&runs[3]));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_inside_multi_byte_string_literal_advances_to_char_boundary() {
|
|
let theme = dark();
|
|
// 'café' = ['(0)', c(1), a(2), f(3), é(4-5), '(6)] — é is 2 bytes.
|
|
// Cursor at byte 4: inside é. char_end advances to 6.
|
|
let runs = render_input_runs("'café'", 4, &theme, &empty_cache());
|
|
let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect();
|
|
assert_eq!(r_under.len(), 1);
|
|
assert_eq!(r_under[0].byte_range, (4, 6));
|
|
}
|
|
|
|
#[test]
|
|
fn end_of_input_cursor_is_an_empty_range() {
|
|
let runs = render_input_runs("create", 6, &dark(), &empty_cache());
|
|
let last = runs.last().expect("non-empty");
|
|
assert_eq!(last.byte_range, (6, 6));
|
|
assert!(reversed(last));
|
|
}
|
|
|
|
// ---- ambient_hint (stage 5 + stage 8b) ----
|
|
|
|
fn empty_cache() -> crate::completion::SchemaCache {
|
|
crate::completion::SchemaCache::default()
|
|
}
|
|
|
|
fn prose(input: &str, cursor: usize) -> Option<String> {
|
|
match ambient_hint(input, cursor, None, &empty_cache()) {
|
|
Some(AmbientHint::Prose(s)) => Some(s),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
|
|
match ambient_hint(input, cursor, None, &empty_cache()) {
|
|
Some(AmbientHint::Candidates { items, .. }) => Some(items),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_is_none_for_empty_input() {
|
|
assert!(ambient_hint("", 0, None, &empty_cache()).is_none());
|
|
assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_valid_input_invites_submit() {
|
|
let h = prose("create table T with pk", 22).expect("prose hint");
|
|
assert!(h.contains("Enter"), "got {h:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_partial_keyword_position_returns_candidates() {
|
|
// `show` mid-keyword: candidates_at_cursor returns
|
|
// {data, table} filtered by prefix "show" — but
|
|
// "show" doesn't match any keyword's prefix. The
|
|
// partial prefix walk finds `show`; expected set at
|
|
// start-of-input is the entry keywords; none start
|
|
// with "show" except `show` itself. Hmm — let me
|
|
// check the actual semantics: at "show" cursor 4,
|
|
// start = 0, partial = "show", expected = entry
|
|
// keywords. Filter by "show" → just `show`. Single
|
|
// candidate.
|
|
let cs = cands_hint("show", 4).expect("candidate hint");
|
|
assert_eq!(cs, vec!["show".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_word_boundary_after_show_returns_data_table() {
|
|
let cs = cands_hint("show ", 5).expect("candidate hint");
|
|
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_definite_error_includes_usage_template() {
|
|
let h = prose("insert into T extra", 19).expect("prose hint");
|
|
assert!(
|
|
h.contains("usage:"),
|
|
"definite-error hint should include usage template, got {h:?}",
|
|
);
|
|
assert!(
|
|
h.contains("insert into <Table>"),
|
|
"should reference the insert usage template, got {h:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_unknown_command_falls_back_to_message() {
|
|
// `frobulate widgets` cursor at start: candidates are
|
|
// computed first; "frobulate" doesn't match any
|
|
// keyword, so candidates = empty → falls back to
|
|
// prose error message.
|
|
let h = prose("frobulate widgets", 17).expect("prose hint");
|
|
assert!(
|
|
!h.contains("usage:"),
|
|
"no entry keyword consumed → no usage template; got {h:?}",
|
|
);
|
|
assert!(
|
|
h.contains("frobulate"),
|
|
"message should mention the unknown word; got {h:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_invalid_identifier_says_no_such() {
|
|
use crate::completion::SchemaCache;
|
|
// Schema knows "Customers"; user typed "Custp" — no match.
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
match ambient_hint("show data Custp", 15, None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.contains("no such table"),
|
|
"expected 'no such table' wording, got {p:?}",
|
|
);
|
|
assert!(p.contains("Custp"), "should name the bad ident, got {p:?}");
|
|
}
|
|
other => panic!("expected Prose for invalid-ident, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_with_memo_carries_selected_index() {
|
|
use crate::completion::LastCompletion;
|
|
// Simulate the post-Tab state at "show " — but with
|
|
// the original word still pending (cursor placed
|
|
// after `show ` to expose the multi-candidate slot).
|
|
// The memo's selection_idx is what the renderer uses
|
|
// to highlight one of the items.
|
|
let memo = LastCompletion {
|
|
inserted_range: (5, 5),
|
|
original_text: String::new(),
|
|
candidates: vec!["data".to_string(), "table".to_string()],
|
|
selection_idx: 1,
|
|
};
|
|
match ambient_hint("show ", 5, Some(&memo), &empty_cache()) {
|
|
Some(AmbientHint::Candidates { items, selected }) => {
|
|
assert_eq!(items, vec!["data".to_string(), "table".to_string()]);
|
|
assert_eq!(selected, Some(1));
|
|
}
|
|
other => panic!("expected Candidates, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- classify_input + error overlay (stage 4) ----
|
|
|
|
#[test]
|
|
fn classify_empty_input() {
|
|
assert_eq!(classify_input(""), InputState::Empty);
|
|
assert_eq!(classify_input(" "), InputState::Empty);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_complete_command_is_valid() {
|
|
assert_eq!(
|
|
classify_input("create table Customers with pk"),
|
|
InputState::Valid,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_partial_keyword_only_is_incomplete() {
|
|
// `create` alone — parser fails at EOF expecting `table`.
|
|
assert_eq!(classify_input("create"), InputState::IncompleteAtEof);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_partial_command_mid_clause_is_incomplete() {
|
|
assert_eq!(
|
|
classify_input("create table Customers"),
|
|
InputState::IncompleteAtEof,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_unknown_command_is_definite_error_at_zero() {
|
|
assert_eq!(
|
|
classify_input("frobulate widgets"),
|
|
InputState::DefiniteErrorAt(0),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_wrong_token_mid_command_is_definite_error_at_token_position() {
|
|
// `create table` consumed (12 bytes inc. trailing space
|
|
// skipped by lexer); `1Bad` lexes as Number(13)+Identifier(14).
|
|
// Parser expects ident at position 13, finds Number — fails.
|
|
let state = classify_input("create table 1Bad");
|
|
match state {
|
|
InputState::DefiniteErrorAt(pos) => assert_eq!(pos, 13),
|
|
other => panic!("expected DefiniteErrorAt(13), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_trailing_whitespace_does_not_create_definite_error() {
|
|
// Trailing whitespace alone shouldn't promote an
|
|
// incomplete-at-EOF state into a definite error.
|
|
assert_eq!(
|
|
classify_input("create "),
|
|
InputState::IncompleteAtEof,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_runs_overlays_error_on_failing_token() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("frobulate widgets", 17, &theme, &empty_cache());
|
|
// First run is `frobulate` at (0,9). Should be tok_error
|
|
// colour (definite error overlay).
|
|
assert_eq!(runs[0].byte_range, (0, 9));
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_error));
|
|
// Second run is whitespace, third is `widgets` — these
|
|
// don't get the overlay (only the failing token).
|
|
let widgets = runs.iter().find(|r| r.byte_range == (10, 17));
|
|
assert!(widgets.is_some());
|
|
assert_eq!(
|
|
widgets.unwrap().style.fg,
|
|
Some(theme.tok_identifier),
|
|
"tokens after the error stay in their lex-class colour",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_runs_does_not_overlay_for_incomplete_input() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create", 6, &theme, &empty_cache());
|
|
// No error overlay — `create` keeps tok_keyword.
|
|
assert_eq!(runs[0].byte_range, (0, 6));
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_keyword));
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_runs_does_not_overlay_for_valid_input() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create table T with pk", 22, &theme, &empty_cache());
|
|
// None of the tokens should be tok_error.
|
|
for r in &runs {
|
|
assert_ne!(
|
|
r.style.fg,
|
|
Some(theme.tok_error),
|
|
"no error overlay for valid input: {r:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn full_valid_command_lexes_to_each_token_class() {
|
|
// Use a valid command — `update ... --all-rows` —
|
|
// so the error overlay (stage 4) doesn't replace any
|
|
// class colours with tok_error. Tokens: keyword(s),
|
|
// identifier(s), string literal, punct (=), flag.
|
|
let theme = dark();
|
|
let input = "update T set Name='hi' --all-rows";
|
|
let runs = render_input_runs(input, input.len(), &theme, &empty_cache());
|
|
let fgs: Vec<_> = runs.iter().filter_map(|r| r.style.fg).collect();
|
|
assert!(fgs.contains(&theme.tok_keyword)); // update / set
|
|
assert!(fgs.contains(&theme.tok_identifier)); // T / Name
|
|
assert!(fgs.contains(&theme.tok_string)); // 'hi'
|
|
assert!(fgs.contains(&theme.tok_punct)); // =
|
|
assert!(fgs.contains(&theme.tok_flag)); // --all-rows
|
|
// The valid command must not have any error overlay.
|
|
for r in &runs {
|
|
assert_ne!(r.style.fg, Some(theme.tok_error));
|
|
}
|
|
}
|
|
}
|