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
+140
View File
@@ -30,6 +30,73 @@ use crate::dsl::walker::outcome::{
pub use context::ColumnInfo;
pub use highlight::highlight_runs;
/// Resolve the hint-panel mode at the end of `source` (ADR-0024
/// §HintMode-per-node).
///
/// Today this is a detection-based bridge: the walker's
/// expected-set is pattern-matched for the value-literal slot
/// signature (`null`/`true`/`false`/number/string) and for
/// `Ident { source: NewName }`. The mapping is exactly what the
/// post-hoc ad-hoc cases in `input_render.rs::ambient_hint`
/// used to compute inline — relocated to one place so the hint
/// resolver dispatches on a `HintMode` enum rather than
/// rediscovering the cases at every call site.
///
/// Phase D will replace this with node-attached `HintMode`
/// annotations on the typed value slots (so `date_slot` carries
/// `ProseOnly("hint.date_format")`, `int_slot` carries an
/// integer-specific hint, etc.). The signature pattern-match
/// here becomes obsolete once that lands.
#[must_use]
pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode> {
use crate::dsl::grammar::{HintMode, IdentSource};
use crate::dsl::walker::outcome::Expectation;
let expected = expected_at_input(source);
if expected.is_empty() {
return None;
}
// Value-literal slot signature: all five forms present.
let has_word = |w: &str| {
expected
.iter()
.any(|e| matches!(e, Expectation::Word(x) if *x == w))
};
let value_literal_slot = has_word("null")
&& has_word("true")
&& has_word("false")
&& expected.iter().any(|e| matches!(e, Expectation::NumberLit))
&& expected.iter().any(|e| matches!(e, Expectation::StringLit));
if value_literal_slot {
// The catalog wording lists all valid literal forms with
// format examples. Phase D will narrow per column type.
return Some(HintMode::ProseOnly("hint.value_literal_slot"));
}
// NewName ident slot: user invents a name.
let new_name_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::NewName,
..
}
)
});
if new_name_slot {
// The "Type a name" prose key is selected by the
// ambient_hint dispatch — the `ForceProse` key here is
// a stable identifier the resolver maps to one of the
// two variants (`hint.ambient_typing_name` /
// `hint.ambient_typing_name_then`) depending on whether
// a next-token probe yields content.
return Some(HintMode::ForceProse("hint.ambient_typing_name"));
}
None
}
/// What the grammar would accept at the end of `source`
/// (ADR-0024 §architecture, Phase F walker-driven completion).
///
@@ -1025,4 +1092,77 @@ mod tests {
}
);
}
// =========================================================
// hint_mode_at_input (ADR-0024 §HintMode-per-node)
// =========================================================
use crate::dsl::grammar::HintMode;
use super::hint_mode_at_input;
#[test]
fn hint_mode_value_literal_slot_after_insert_open_paren() {
// `insert into T (` expects a value-literal or column
// ident at the inner position. After `values (` it's
// strictly value-literals — the signature triggers
// ProseOnly.
match hint_mode_at_input("insert into T values (") {
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
}
}
#[test]
fn hint_mode_value_literal_slot_after_update_set_assign() {
match hint_mode_at_input("update T set col=") {
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
}
}
#[test]
fn hint_mode_value_literal_slot_in_where_clause() {
match hint_mode_at_input("delete from T where col=") {
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
}
}
#[test]
fn hint_mode_new_name_slot_for_create_table() {
// `create table ` expects a NewName ident.
match hint_mode_at_input("create table ") {
Some(HintMode::ForceProse("hint.ambient_typing_name")) => {}
other => panic!("expected ForceProse typing_name, got {other:?}"),
}
}
#[test]
fn hint_mode_new_name_slot_for_add_column_name() {
// `add column T: ` expects a NewName ident.
match hint_mode_at_input("add column to table T: ") {
Some(HintMode::ForceProse("hint.ambient_typing_name")) => {}
other => panic!("expected ForceProse typing_name, got {other:?}"),
}
}
#[test]
fn hint_mode_none_for_keyword_position() {
// Entry-keyword position: no HintMode override applies.
assert!(hint_mode_at_input("").is_none());
assert!(hint_mode_at_input("cr").is_none());
}
#[test]
fn hint_mode_none_for_complete_command() {
// Valid complete command: no expected, no override.
assert!(hint_mode_at_input("create table T with pk").is_none());
}
#[test]
fn hint_mode_none_at_schema_ident_slot() {
// `show data ` expects a table-name ident from the
// schema — schema-listable slot, not a HintMode case.
assert!(hint_mode_at_input("show data ").is_none());
}
}