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:
+18
-6
@@ -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
|
/// `Default` (today's behaviour) shows candidates if any, falls
|
||||||
/// attaches to typed value slots in Phase D so the hint reads
|
/// back to a prose ladder otherwise. The other variants
|
||||||
/// "Type a date as 'YYYY-MM-DD'" rather than candidate-cycling.
|
/// override at slot positions where the candidate list would be
|
||||||
#[derive(Debug, Clone, Copy)]
|
/// actively misleading or where the user benefits from format
|
||||||
#[allow(dead_code)]
|
/// 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 {
|
pub enum HintMode {
|
||||||
Default,
|
Default,
|
||||||
ForceProse(&'static str),
|
ForceProse(&'static str),
|
||||||
|
|||||||
@@ -30,6 +30,73 @@ use crate::dsl::walker::outcome::{
|
|||||||
pub use context::ColumnInfo;
|
pub use context::ColumnInfo;
|
||||||
pub use highlight::highlight_runs;
|
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`
|
/// What the grammar would accept at the end of `source`
|
||||||
/// (ADR-0024 §architecture, Phase F walker-driven completion).
|
/// (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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-23
@@ -177,35 +177,60 @@ pub fn ambient_hint(
|
|||||||
selected: Some(m.selection_idx),
|
selected: Some(m.selection_idx),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// No memo: fall back to candidates_at_cursor. When any
|
// Resolve the walker-side `HintMode` at the cursor. This
|
||||||
// exist the user can Tab to insert one, and the panel
|
// detects value-literal-slot and NewName-slot positions
|
||||||
// surfaces them directly — this wins over the prose
|
// declaratively (ADR-0024 §HintMode-per-node) — the
|
||||||
// IncompleteAtEof framing because the candidate list is
|
// ambient-hint ladder dispatches on the returned variant
|
||||||
// more actionable.
|
// 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) {
|
if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) {
|
||||||
return Some(AmbientHint::Candidates {
|
return Some(AmbientHint::Candidates {
|
||||||
items: comp.candidates,
|
items: comp.candidates,
|
||||||
selected: None,
|
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
|
// Invalid identifier: cursor sits in a known-set slot but
|
||||||
// the typed prefix matches nothing in the schema. (Stage
|
// the typed prefix matches nothing in the schema. (Stage
|
||||||
// 8e / the user's #5.)
|
// 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
|
/// "A, B, or C" / "A or B" / "A". Local copy because the
|
||||||
/// parser's identical helper is private.
|
/// parser's identical helper is private.
|
||||||
fn oxford_or(items: &[String]) -> String {
|
fn oxford_or(items: &[String]) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user