From 85817791dc49ba99d3091e54aca505c47992b71d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 15 May 2026 17:32:17 +0000 Subject: [PATCH] ADR-0024 HintMode dispatch via walker_hint_mode_at_input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `HintMode` dispatch layer the ADR specified: the ambient-hint resolver now consults a single `walker::hint_mode_at_input(source) -> Option` 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. --- src/dsl/grammar/mod.rs | 24 +++++-- src/dsl/walker/mod.rs | 140 +++++++++++++++++++++++++++++++++++++++++ src/input_render.rs | 103 +++++++++++++++++++++++------- 3 files changed, 238 insertions(+), 29 deletions(-) diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index effbe0d..fa6e8e5 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -119,13 +119,25 @@ impl IdentSource { } } -/// Hint-panel mode for an expected node. +/// Hint-panel mode for an expected node (ADR-0024 §HintMode-per-node). /// -/// Phase A defaults to `Default`; the `ProseOnly` variant -/// attaches to typed value slots in Phase D so the hint reads -/// "Type a date as 'YYYY-MM-DD'" rather than candidate-cycling. -#[derive(Debug, Clone, Copy)] -#[allow(dead_code)] +/// `Default` (today's behaviour) shows candidates if any, falls +/// back to a prose ladder otherwise. The other variants +/// override at slot positions where the candidate list would be +/// actively misleading or where the user benefits from format +/// guidance: +/// +/// - `ProseOnly(catalog_key)` — show only prose from the +/// catalog; suppress Tab candidates. Used today by the +/// value-literal slot at empty prefix (the "null/true/false" +/// candidate trio is misleading at a slot that more often +/// takes a number / quoted text / date). +/// - `ForceProse(catalog_key)` — force this prose at the +/// catalog key regardless of candidates. Used today by +/// `NewName` ident slots ("Type a name, then `(`"). +/// - `SuppressProse` — show only candidates; never fall back +/// to a prose ladder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HintMode { Default, ForceProse(&'static str), diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 9558897..c648f78 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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 { + 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()); + } } diff --git a/src/input_render.rs b/src/input_render.rs index bad6fe5..96706df 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -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 ]". + // 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 {