ADR-0022 follow-up r3: identifier colour, NewName hint, "Next:" wording, "type" label

Three fixes from a third round of real testing.

1. **tok_identifier vivid (round-3 #1).** The cool grey-blue
   from r2 was still too close to theme.fg to register as
   distinct. Bumped to cyan-teal (#56B6C2 dark / #0F6B76
   light) — identifiers are the user's most "special" content
   and now read that way against keywords (purple), numbers
   (orange), strings (green), and flags (amber).

2. **"Type a name" hint at NewName slots (round-3 #2).**
   New `completion::typing_name_at_cursor(input, cursor)`
   returns `Some(TypingName)` when the cursor sits at — or
   inside — an `IdentSlot::NewName` position. It probes by
   substituting a single-letter placeholder identifier and
   re-parsing to discover what the parser would expect AFTER
   the name; the hint then reads "Type a name, then `(`"
   instead of the technical "next: `(`" that surfaces once
   the partial identifier has been consumed by the live
   parser. When the probe yields nothing useful (custom
   errors with empty expected, or a complete-on-substitute
   case), falls back to "Type a name".

   New catalog keys hint.ambient_typing_name and
   hint.ambient_typing_name_then. Wired into ambient_hint
   between the candidate-list and invalid-ident checks.

3. **"Next:" instead of "expected:" wording.** "Expected"
   read as a leaked diagnostic; "Next:" is shorter,
   conversational, and consistent with the action-oriented
   voice of "Submit with Enter" and "Type a name". Hint
   sentences now also start capitalised
   (Submit/Next/Type/No-such), per the user's Capital-T-on-
   "type a name" preference.

4. **type_keyword labelled "type".** Without a label, the
   `select_ref!` over an Identifier token produced
   `RichPattern::SomethingElse`, which rendered as the
   meaningless "something else" in the hint after `(`.
   Labelled now: error reads "Next: type" — terse but
   honest. The label is applied BEFORE try_map (not after,
   not via as_context) so the existing custom-error wording
   for unknown types ("unknown type 'varchar' (expected one
   of: …)") still surfaces unchanged.

Tests: 755 passing, 0 failing, 1 ignored (no net change —
+5 typing_name cases, -0 net since one test was reworded
for capitalisation rather than added). Clippy clean.

Smoke probe verifies: "add column to table T: " → "Type a
name, then `(`"; "add column to table T: Name (" → "Next:
type"; "show data Custp" → "No such table: `Custp`"; valid
input → "Submit with Enter".

Note for next testing round: parser-side custom errors
(e.g. the "tables need at least one column" message that
fires for `create table Customers `) still read in
lowercase — they're hand-written in parser.rs source rather
than via the catalog. If the lowercase "tables need…"
intrusion bothers you, easy follow-up.
This commit is contained in:
claude@clouddev1
2026-05-11 22:41:23 +00:00
parent f94a999e66
commit 22119d6a4e
6 changed files with 175 additions and 7 deletions
+131
View File
@@ -199,6 +199,89 @@ pub struct InvalidIdent {
pub slot: IdentSlot,
}
/// "User is typing a name" cursor state (round-3 follow-up).
///
/// Fires at `NewName` slots — positions where the user is
/// expected to invent a name (new table, new column, new
/// relationship). Used by the hint panel to surface a friendly
/// "Type a name" hint instead of the technical "next: `(`"
/// that would otherwise appear once the partial identifier
/// gets consumed by the parser.
///
/// `next_after_name` is what the parser would expect once the
/// user finishes typing the name — derived by re-parsing with
/// a single-letter placeholder identifier substituted at the
/// cursor. `None` when the post-name parse succeeds (the rest
/// of the command is already in place) or has no meaningful
/// next-token information (custom errors with empty expected
/// set).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypingName {
pub next_after_name: Option<String>,
}
/// `Some(_)` when the cursor is at or inside a `NewName`-slot
/// position. Otherwise `None`.
#[must_use]
pub fn typing_name_at_cursor(input: &str, cursor: usize) -> Option<TypingName> {
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;
}
}
let leading = &input[..start];
let expected = expected_set(leading);
let is_new_name_slot = expected
.iter()
.filter_map(|item| IdentSlot::from_expected_label(item))
.any(|slot| slot == IdentSlot::NewName);
if !is_new_name_slot {
return None;
}
// Probe what comes after the name by substituting a
// single-letter identifier placeholder. Walk forward over
// any partial text past the cursor first so the probe
// replaces the user's in-progress name as a whole.
let mut end = cursor;
while end < bytes.len() {
let c = bytes[end];
if c.is_ascii_alphanumeric() || c == b'_' {
end += 1;
} else {
break;
}
}
let probe = format!("{}X{}", &input[..start], &input[end..]);
let next_after_name = match parse_command(&probe) {
Ok(_) => None,
Err(ParseError::Empty) => None,
Err(ParseError::Invalid { expected, .. }) if expected.is_empty() => None,
Err(ParseError::Invalid { expected, .. }) => Some(oxford_or(&expected)),
};
Some(TypingName { next_after_name })
}
/// English-style "A, B, or C" join used by the hint panel
/// prose. Lifted out of `input_render` so the completion
/// module can produce ready-to-render strings.
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(", "))
}
}
}
/// Detect "the user has typed an identifier here that the
/// schema doesn't have." Returns `None` for any of:
/// - cursor at empty / whitespace partial;
@@ -554,6 +637,54 @@ mod tests {
assert!(cs.is_empty(), "got {cs:?}");
}
// ---- typing_name_at_cursor (round-3 follow-up) ----
#[test]
fn typing_name_fires_at_new_column_slot_with_next_token() {
// After `add column to table T: ` the parser expects
// an identifier (NewName slot) followed by `(`. The
// probe substitutes a placeholder name and reads back
// that the next token is `(`.
let t = typing_name_at_cursor("add column to table T: ", 23)
.expect("should fire at NewName slot");
assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
}
#[test]
fn typing_name_fires_when_partial_name_already_typed() {
// Mid-typing the column name. typing_name_at_cursor
// walks back over the partial to find the slot, then
// probes forward as if the partial were a complete name.
let t = typing_name_at_cursor("add column to table T: Na", 25)
.expect("should fire at NewName slot with partial");
assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
}
#[test]
fn typing_name_does_not_fire_at_table_name_slot() {
// `show data ` — the slot is TableName, not NewName.
// The candidates path (or invalid-ident) handles it;
// typing_name should not fire.
assert!(typing_name_at_cursor("show data ", 10).is_none());
}
#[test]
fn typing_name_does_not_fire_at_keyword_slot() {
// `cr` at position 2 is a keyword slot.
assert!(typing_name_at_cursor("cr", 2).is_none());
}
#[test]
fn typing_name_yields_no_next_when_probe_succeeds() {
// `add column to table T: Name (text)` — the user is
// inside `Name`, and substituting any name there
// produces a complete command. No useful "next after
// name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
.expect("should fire");
assert_eq!(t.next_after_name, None);
}
// ---- invalid_ident_at_cursor (stage 8e) ----
#[test]