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:
@@ -143,6 +143,87 @@ fn strip_backticks(s: &str) -> Option<&str> {
|
||||
s.strip_prefix('`').and_then(|s| s.strip_suffix('`'))
|
||||
}
|
||||
|
||||
/// What the user has typed in an identifier slot whose schema
|
||||
/// list contains nothing matching the prefix (ADR-0022 stage 8e
|
||||
/// + the user's #5).
|
||||
///
|
||||
/// The renderer overlays the partial token with `tok_error`;
|
||||
/// the hint panel renders an "invalid …" message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InvalidIdent {
|
||||
/// Byte range of the typed-but-not-found identifier.
|
||||
pub range: (usize, usize),
|
||||
/// The text the user typed in the slot.
|
||||
pub found: String,
|
||||
/// Which known-set slot this position expected.
|
||||
pub slot: IdentSlot,
|
||||
}
|
||||
|
||||
/// Detect "the user has typed an identifier here that the
|
||||
/// schema doesn't have." Returns `None` for any of:
|
||||
/// - cursor at empty / whitespace partial;
|
||||
/// - cursor at a position that doesn't expect a known-set
|
||||
/// identifier (keyword slot, NewName slot, complete input);
|
||||
/// - cursor partial matches at least one schema name.
|
||||
#[must_use]
|
||||
pub fn invalid_ident_at_cursor(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
cache: &SchemaCache,
|
||||
) -> Option<InvalidIdent> {
|
||||
let cursor = cursor.min(input.len());
|
||||
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;
|
||||
}
|
||||
}
|
||||
if start == cursor {
|
||||
// No partial token at the cursor — nothing to flag.
|
||||
return None;
|
||||
}
|
||||
let partial = &input[start..cursor];
|
||||
let leading = &input[..start];
|
||||
let expected = expected_set(leading);
|
||||
if expected.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Find every known-set slot in the expected list.
|
||||
let slots: Vec<IdentSlot> = expected
|
||||
.iter()
|
||||
.filter_map(|item| IdentSlot::from_expected_label(item))
|
||||
.filter(|slot| slot.completes_from_schema())
|
||||
.collect();
|
||||
if slots.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lowered = partial.to_lowercase();
|
||||
// If any schema entry across the matching slots matches
|
||||
// the prefix, the partial is not "invalid" — it's an
|
||||
// in-progress lookup.
|
||||
let any_match = slots
|
||||
.iter()
|
||||
.flat_map(|slot| cache.for_slot(*slot))
|
||||
.any(|name| name.to_lowercase().starts_with(&lowered));
|
||||
if any_match {
|
||||
return None;
|
||||
}
|
||||
// Pick the first slot kind for the diagnostic — when
|
||||
// multiple are expected (e.g. `drop relationship …`
|
||||
// expects RelationshipName *or* the `from` keyword;
|
||||
// here only the schema slot survives the filter) we
|
||||
// surface the first.
|
||||
Some(InvalidIdent {
|
||||
range: (start, cursor),
|
||||
found: partial.to_string(),
|
||||
slot: slots[0],
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -372,6 +453,65 @@ mod tests {
|
||||
assert!(cs.is_empty(), "got {cs:?}");
|
||||
}
|
||||
|
||||
// ---- invalid_ident_at_cursor (stage 8e) ----
|
||||
|
||||
#[test]
|
||||
fn invalid_ident_fires_for_unknown_table_prefix() {
|
||||
let cache = SchemaCache {
|
||||
tables: vec!["Customers".to_string()],
|
||||
..SchemaCache::default()
|
||||
};
|
||||
// `show data Cust` matches → no invalid.
|
||||
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
|
||||
// `show data Cust` plus a typo: `show data Custp`. No
|
||||
// table starts with "Custp" → invalid.
|
||||
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
|
||||
.expect("should be invalid");
|
||||
assert_eq!(invalid.range, (10, 15));
|
||||
assert_eq!(invalid.found, "Custp");
|
||||
assert_eq!(invalid.slot, IdentSlot::TableName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ident_does_not_fire_when_partial_matches_some_schema_entry() {
|
||||
let cache = SchemaCache {
|
||||
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
||||
..SchemaCache::default()
|
||||
};
|
||||
// "C" matches Customers (prefix), so not invalid.
|
||||
assert!(invalid_ident_at_cursor("show data C", 11, &cache).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ident_does_not_fire_in_new_name_slot() {
|
||||
// `create table Cust` — Cust is a NewName slot. Even
|
||||
// if no schema entry matches, the user invents the
|
||||
// name; not invalid.
|
||||
let cache = SchemaCache {
|
||||
tables: vec!["Existing".to_string()],
|
||||
..SchemaCache::default()
|
||||
};
|
||||
assert!(invalid_ident_at_cursor("create table Cust", 17, &cache).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ident_does_not_fire_when_cursor_not_at_partial_token() {
|
||||
let cache = SchemaCache::default();
|
||||
// Cursor at a whitespace position — no partial token.
|
||||
assert!(invalid_ident_at_cursor("show data ", 10, &cache).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ident_does_not_fire_at_keyword_slot() {
|
||||
// `cra` at the entry-keyword position — no keyword
|
||||
// starts with "cra", but the slot is keyword (not a
|
||||
// known-schema slot), so invalid_ident doesn't fire.
|
||||
// The render path's regular parse-error overlay handles
|
||||
// this case.
|
||||
let cache = SchemaCache::default();
|
||||
assert!(invalid_ident_at_cursor("cra", 3, &cache).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_name_slot_offers_no_candidates_even_with_populated_cache() {
|
||||
// `create table ` — the table-name slot is NewName.
|
||||
|
||||
Reference in New Issue
Block a user