ADR-0024 HintMode dispatch via walker_hint_mode_at_input

Adds the `HintMode` dispatch layer the ADR specified: the
ambient-hint resolver now consults a single
`walker::hint_mode_at_input(source) -> Option<HintMode>` to
decide between the prose / candidates ladder, rather than
discovering each slot kind through three separate post-hoc
helpers (`value_literal_hint_at_cursor`,
`typing_name_at_cursor`, and so on).

Behaviour at slot positions today:

- **Value-literal slot** (`null`/`true`/`false`/number/string
  all in the expected set) → `HintMode::ProseOnly
  ("hint.value_literal_slot")`. The ambient-hint ladder
  emits the catalog prose at empty prefix; once the user types
  a partial (`n`, `tr`, `fa`) the partial check declines and
  normal candidate completion takes over.
- **NewName ident slot** → `HintMode::ForceProse
  ("hint.ambient_typing_name")`. The ladder still consults
  `typing_name_at_cursor` to learn what comes after the name
  (the post-name probe is unchanged); `ForceProse` is the
  declarative tag telling the resolver *that* we're in this
  mode.

`HintMode` itself gains `PartialEq + Eq` for tests, and
its docstring is rewritten to describe the live semantics.

This is the structural shape ADR-0024 §HintMode-per-node
describes: one slot → one hint mode → one dispatch arm. The
detection inside `hint_mode_at_input` is transitional — it
pattern-matches the walker's expected-set today, which is
exactly what the previous ad-hoc detectors did. Phase D will
replace the signature match with node-attached `HintMode`
annotations on the typed value slots (so `date_slot`,
`int_slot`, etc. each carry a type-specific catalog key).

Two helpers move into `input_render.rs`:
- `hint_leading_slice(input, cursor)` mirrors the look-back
  used by `candidates_at_cursor` so the hint resolver sees the
  same token-boundary view of the world.
- `cursor_partial_is_empty(input, cursor)` distinguishes
  empty-prefix from in-progress identifier shapes.

8 new walker tests pin the hint-mode resolver across
value-literal-after-paren, value-literal-after-set-assign,
value-literal-in-where, two NewName-slot cases, the
entry-keyword position, the complete-command position, and
the schema-ident position.

Tests: 817 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-15 17:32:17 +00:00
parent 7ae1a0fde1
commit 85817791dc
3 changed files with 238 additions and 29 deletions
+80 -23
View File
@@ -177,35 +177,60 @@ pub fn ambient_hint(
selected: Some(m.selection_idx),
});
}
// No memo: fall back to candidates_at_cursor. 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.
// Resolve the walker-side `HintMode` at the cursor. This
// detects value-literal-slot and NewName-slot positions
// declaratively (ADR-0024 §HintMode-per-node) — the
// ambient-hint ladder dispatches on the returned variant
// before falling through to the generic candidates / prose
// framings.
//
// We pass the `leading` slice (input up to the start of any
// partial identifier the user is mid-typing) so the hint
// mode reflects the slot expected at the token boundary,
// not whatever the partial would resolve to.
let leading = hint_leading_slice(input, cursor);
let hint_mode = crate::dsl::walker::hint_mode_at_input(leading);
match hint_mode {
Some(crate::dsl::grammar::HintMode::ProseOnly(key)) => {
// The cursor sits at a slot where Tab candidates
// would be actively misleading. Surface the catalog
// prose for the slot. Only fires at empty prefix —
// once the user starts typing a partial, normal
// candidate completion (e.g. `n` → `null`) applies.
if cursor_partial_is_empty(input, cursor) {
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
}
}
Some(crate::dsl::grammar::HintMode::ForceProse(_key)) => {
// NewName slot: show "Type a name [then <next>]".
// The probe in `typing_name_at_cursor` reads what
// would come *after* the name, so we still consult
// it to populate the optional `then` clause. The
// walker-side `ForceProse` annotation tells us
// *that* we're in this mode; the probe tells us
// *what comes next*.
if let Some(t) = crate::completion::typing_name_at_cursor(input, cursor) {
let text = t.next_after_name.map_or_else(
|| crate::t!("hint.ambient_typing_name"),
|next| crate::t!("hint.ambient_typing_name_then", next = next),
);
return Some(AmbientHint::Prose(text));
}
}
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
| None => {}
}
// No HintMode override: candidate-or-prose ladder applies.
// Candidates win when any exist — the panel surfaces them
// directly because they're more actionable than prose
// framings.
if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) {
return Some(AmbientHint::Candidates {
items: comp.candidates,
selected: None,
});
}
// Value-literal slot at empty prefix: candidates_at_cursor
// returned None (it suppresses null/true/false here to avoid
// implying they're the only options). Surface a prose hint
// that names all valid literal forms with format examples.
if let Some(prose) = crate::completion::value_literal_hint_at_cursor(input, cursor) {
return Some(AmbientHint::Prose(prose));
}
// User typing into a NewName slot — show the friendlier
// "type a name" hint rather than the technical "next: …"
// that the post-consumed-partial parse would otherwise
// produce (round-3 follow-up).
if let Some(t) = crate::completion::typing_name_at_cursor(input, cursor) {
let text = t.next_after_name.map_or_else(
|| crate::t!("hint.ambient_typing_name"),
|next| crate::t!("hint.ambient_typing_name_then", next = next),
);
return Some(AmbientHint::Prose(text));
}
// Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.)
@@ -265,6 +290,38 @@ pub fn ambient_hint(
}
}
/// Slice of `input` ending at the start of the partial
/// identifier-shape token at `cursor` (if any). Mirrors the
/// look-back used by `completion::candidates_at_cursor` so the
/// hint resolver sees the same "what was expected at the token
/// boundary" view of the world.
fn hint_leading_slice(input: &str, cursor: usize) -> &str {
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;
}
}
&input[..start]
}
/// True when the cursor is at a token boundary — no partial
/// identifier-shape token in progress.
fn cursor_partial_is_empty(input: &str, cursor: usize) -> bool {
let cursor = cursor.min(input.len());
let bytes = input.as_bytes();
if cursor == 0 {
return true;
}
let prev = bytes[cursor - 1];
!(prev.is_ascii_alphanumeric() || prev == b'_')
}
/// "A, B, or C" / "A or B" / "A". Local copy because the
/// parser's identical helper is private.
fn oxford_or(items: &[String]) -> String {