ADR-0022 stage 8e: invalid-identifier detection + hint variant

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.
This commit is contained in:
claude@clouddev1
2026-05-11 21:01:44 +00:00
parent 7a32c13bd5
commit 8214e4136a
5 changed files with 218 additions and 15 deletions
+64 -14
View File
@@ -53,15 +53,25 @@ impl StyledRun {
///
/// 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), preserves whitespace
/// 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) -> Vec<StyledRun> {
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
}
@@ -164,6 +174,26 @@ pub fn ambient_hint(
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"))),
@@ -335,7 +365,7 @@ mod tests {
#[test]
fn empty_input_renders_only_the_end_of_input_cursor() {
let runs = render_input_runs("", 0, &dark());
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]));
@@ -344,7 +374,7 @@ mod tests {
#[test]
fn keyword_token_takes_keyword_colour() {
let theme = dark();
let runs = render_input_runs("create", 6, &theme);
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));
@@ -356,7 +386,7 @@ mod tests {
#[test]
fn cursor_inside_token_splits_into_three_runs_keeping_colour() {
let theme = dark();
let runs = render_input_runs("create", 3, &theme);
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));
@@ -374,7 +404,7 @@ mod tests {
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);
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.
@@ -388,7 +418,7 @@ mod tests {
#[test]
fn lex_error_token_renders_in_error_colour() {
let theme = dark();
let runs = render_input_runs("$", 1, &theme);
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));
@@ -397,7 +427,7 @@ mod tests {
#[test]
fn whitespace_between_tokens_takes_default_fg() {
let theme = dark();
let runs = render_input_runs("create table", 12, &theme);
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);
@@ -412,7 +442,7 @@ mod tests {
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);
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));
@@ -420,7 +450,7 @@ mod tests {
#[test]
fn end_of_input_cursor_is_an_empty_range() {
let runs = render_input_runs("create", 6, &dark());
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));
@@ -510,6 +540,26 @@ mod tests {
);
}
#[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;
@@ -596,7 +646,7 @@ mod tests {
#[test]
fn render_input_runs_overlays_error_on_failing_token() {
let theme = dark();
let runs = render_input_runs("frobulate widgets", 17, &theme);
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));
@@ -615,7 +665,7 @@ mod tests {
#[test]
fn render_input_runs_does_not_overlay_for_incomplete_input() {
let theme = dark();
let runs = render_input_runs("create", 6, &theme);
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));
@@ -624,7 +674,7 @@ mod tests {
#[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);
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!(
@@ -643,7 +693,7 @@ mod tests {
// 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);
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