ADR-0024 Phase D: per-column-type hint prose at value slots
The Phase D commit landed parse-time validation but not the
user-facing payoff — per-column-type hints. Typing
`insert into Customers values (` rightfully expected a hint
like "Type an integer (e.g. 42, -7) or null" at an int column.
This commit closes that gap.
End-to-end:
**`Node::TypedValueSlot { ty, inner }`** (new variant in
`src/dsl/grammar/mod.rs`):
- Walker walks `inner` to consume the literal but tags
`WalkContext::pending_value_type = Some(ty)` on entry, then
clears it on a successful inner match. Positions BETWEEN
slots (`insert into T values (1` mid-input) thus don't carry
a stale hint type.
**Typed slot factories wrapped in `TypedValueSlot`**
(`src/dsl/grammar/shared.rs`):
- `INT_SLOT`, `REAL_SLOT`, `DECIMAL_SLOT`, `BOOL_SLOT`,
`TEXT_SLOT`, `DATE_SLOT`, `DATETIME_SLOT`, `BLOB_SLOT`,
`SERIAL_SLOT`, `SHORTID_SLOT` — each pairs an inner literal
Choice with its `Type` so the walker can tag context.
- `slot_for_type(ty)` dispatches to the appropriate constant.
- Bug fix: `ShortId` previously dispatched to `INT_SLOT` (a
pre-Phase-D holdover from the chumsky-side generic
fallback). `shortid` columns store base58 text (ADR-0011
fk_target_type shortid → text); the corrected slot accepts
`StringLit` or `null`.
**Schema-aware hint resolver** (`src/dsl/walker/mod.rs`):
- `hint_mode_at_input_with_schema(source, &SchemaCache) ->
Option<HintMode>` is the new public entry point. Reads
`pending_value_type` from the walker's WalkContext and
emits `HintMode::ProseOnly("hint.value_slot_<type>")` —
one per Type.
- The schemaless `hint_mode_at_input(source)` falls back to
the generic `hint.value_literal_slot` at value-literal slots
(no per-type narrowing without a schema).
- `catalog_key_for_value_type(ty)` is the type → key
dispatcher.
**Catalog entries** (`src/friendly/strings/en-US.yaml`,
`src/friendly/keys.rs`):
- 10 new `hint.value_slot_<type>` keys with per-type prose:
- int/serial → "Type an integer (e.g. 42, -7) or null"
- real/decimal → "Type a number (e.g. 3.14, -0.5) or null"
- bool → "Type true, false, or null"
- text → "Type a quoted string (e.g. 'Alice') or null"
- date → "Type a quoted date as 'YYYY-MM-DD' or null"
- datetime → "Type a quoted datetime as 'YYYY-MM-DD
HH:MM:SS' or null"
- blob → "Type a quoted blob literal or null"
- shortid → "Type a quoted shortid (or omit to auto-generate)
or null"
**Ambient-hint dispatch** (`src/input_render.rs::ambient_hint`):
- Passes the SchemaCache through to
`hint_mode_at_input_with_schema`, so the live hint panel
surfaces per-column-type prose as the user types into a
value slot.
Tests:
- 8 walker-side tests cover insert / update / where typed-slot
hint dispatch, mid-value no-stale-hint behaviour, and a
full-coverage routing matrix for every `Type` variant.
- 4 input_render integration tests cover the end-to-end
ambient_hint path: insert first/second value, update set
value, and the schemaless fallback to generic prose.
Tests: 842 passing, 0 failing, 1 ignored. Clippy clean.
For the user: typing `insert into Customers values (` against
a Customers table whose first column is `id:int` now shows
"Type an integer (e.g. 42, -7) or null" in the hint panel,
replacing the previous generic value-literal prose. After
typing `1, `, the panel updates to whatever the second column
requires — "Type a quoted string (e.g. 'Alice') or null"
for text, "Type a quoted date as 'YYYY-MM-DD'" for date, etc.
This commit is contained in:
+194
-30
@@ -30,25 +30,44 @@ 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).
|
||||
/// Resolve the hint-panel mode at the end of `source`
|
||||
/// (ADR-0024 §HintMode-per-node, §Phase D §typed-value-slots).
|
||||
///
|
||||
/// 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.
|
||||
/// Schemaless variant. Surfaces:
|
||||
/// - `HintMode::ProseOnly("hint.value_literal_slot")` at generic
|
||||
/// value-literal positions (all five forms in the expected
|
||||
/// set), and
|
||||
/// - `HintMode::ForceProse("hint.ambient_typing_name")` at
|
||||
/// `NewName` ident slots.
|
||||
///
|
||||
/// 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.
|
||||
/// Schema-aware callers should use `hint_mode_at_input_with_schema`
|
||||
/// instead — that variant narrows the prose to the column's
|
||||
/// user-facing type at typed value slots (e.g. "Type a date
|
||||
/// as 'YYYY-MM-DD'" at a date column).
|
||||
#[must_use]
|
||||
pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode> {
|
||||
hint_mode_at_input_inner(source, None)
|
||||
}
|
||||
|
||||
/// Schema-aware hint-mode resolution (ADR-0024 §Phase D).
|
||||
///
|
||||
/// Uses the same schema reference the walker drives parse-time
|
||||
/// dispatch from. When the walker enters a `Node::TypedValueSlot`
|
||||
/// at the cursor position, the catalog prose narrows to the
|
||||
/// column's user-facing type (e.g. `hint.value_slot_int` at an
|
||||
/// int column).
|
||||
#[must_use]
|
||||
pub fn hint_mode_at_input_with_schema(
|
||||
source: &str,
|
||||
schema: &crate::completion::SchemaCache,
|
||||
) -> Option<crate::dsl::grammar::HintMode> {
|
||||
hint_mode_at_input_inner(source, Some(schema))
|
||||
}
|
||||
|
||||
fn hint_mode_at_input_inner(
|
||||
source: &str,
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
) -> Option<crate::dsl::grammar::HintMode> {
|
||||
use crate::dsl::grammar::{HintMode, IdentSource};
|
||||
use crate::dsl::walker::outcome::Expectation;
|
||||
|
||||
@@ -59,11 +78,18 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
|
||||
// candidates, but the hint resolver should stay silent so
|
||||
// we don't push prose like "Type a name" at the end of a
|
||||
// valid command.
|
||||
let expected = expected_for_hint(source);
|
||||
let (expected, pending_value_type) = expected_for_hint_with_ctx(source, schema);
|
||||
if expected.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Typed value slot at the cursor: the walker tagged
|
||||
// ctx.pending_value_type on entry to the slot but did not
|
||||
// clear it (no inner literal matched). Emit per-type prose.
|
||||
if let Some(ty) = pending_value_type {
|
||||
return Some(HintMode::ProseOnly(catalog_key_for_value_type(ty)));
|
||||
}
|
||||
|
||||
// Value-literal slot signature: all five forms present.
|
||||
let has_word = |w: &str| {
|
||||
expected
|
||||
@@ -76,8 +102,10 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
|
||||
&& 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.
|
||||
// Fallback prose: lists every literal form with format
|
||||
// examples. Fires when the walker can't resolve a column
|
||||
// type at the cursor (schemaless caller, missing table,
|
||||
// unknown column).
|
||||
return Some(HintMode::ProseOnly("hint.value_literal_slot"));
|
||||
}
|
||||
|
||||
@@ -104,6 +132,22 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
|
||||
None
|
||||
}
|
||||
|
||||
const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str {
|
||||
use crate::dsl::types::Type;
|
||||
match ty {
|
||||
Type::Int => "hint.value_slot_int",
|
||||
Type::Real => "hint.value_slot_real",
|
||||
Type::Decimal => "hint.value_slot_decimal",
|
||||
Type::Bool => "hint.value_slot_bool",
|
||||
Type::Text => "hint.value_slot_text",
|
||||
Type::Date => "hint.value_slot_date",
|
||||
Type::DateTime => "hint.value_slot_datetime",
|
||||
Type::Blob => "hint.value_slot_blob",
|
||||
Type::Serial => "hint.value_slot_serial",
|
||||
Type::ShortId => "hint.value_slot_shortid",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the grammar would accept at the end of `source`
|
||||
/// (ADR-0024 §architecture, Phase F walker-driven completion).
|
||||
///
|
||||
@@ -153,36 +197,46 @@ pub fn expected_at_input(source: &str) -> Vec<outcome::Expectation> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict-required expected set at the end of `source`. Like
|
||||
/// `expected_at_input` but returns empty on `WalkOutcome::Match`
|
||||
/// — optional-suffix continuations are not surfaced. Used by
|
||||
/// the hint resolver to distinguish "must type more" from
|
||||
/// "could continue".
|
||||
#[must_use]
|
||||
fn expected_for_hint(source: &str) -> Vec<outcome::Expectation> {
|
||||
/// Strict-required expected set at the end of `source`, plus
|
||||
/// the walker's `pending_value_type` at the cursor.
|
||||
///
|
||||
/// Like `expected_at_input` but returns empty on
|
||||
/// `WalkOutcome::Match` — optional-suffix continuations are not
|
||||
/// surfaced. Used by the hint resolver to distinguish "must
|
||||
/// type more" from "could continue", and to dispatch per-type
|
||||
/// prose when the cursor is inside a typed value slot.
|
||||
fn expected_for_hint_with_ctx(
|
||||
source: &str,
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
) -> (Vec<outcome::Expectation>, Option<crate::dsl::types::Type>) {
|
||||
use crate::dsl::grammar::REGISTRY;
|
||||
|
||||
if source.trim().is_empty() {
|
||||
return REGISTRY
|
||||
let expected = REGISTRY
|
||||
.iter()
|
||||
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
||||
.collect();
|
||||
return (expected, None);
|
||||
}
|
||||
let mut ctx = context::WalkContext::new();
|
||||
let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
|
||||
context::WalkContext::with_schema(s)
|
||||
});
|
||||
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
|
||||
let Some(result) = result else {
|
||||
return REGISTRY
|
||||
let expected = REGISTRY
|
||||
.iter()
|
||||
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
||||
.collect();
|
||||
return (expected, None);
|
||||
};
|
||||
match result.outcome {
|
||||
let expected = match result.outcome {
|
||||
outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => {
|
||||
Vec::new()
|
||||
}
|
||||
outcome::WalkOutcome::Incomplete { expected, .. }
|
||||
| outcome::WalkOutcome::Mismatch { expected, .. } => expected,
|
||||
}
|
||||
};
|
||||
(expected, ctx.pending_value_type)
|
||||
}
|
||||
|
||||
/// Public walk entry. `bound` is `EndOfInput` for parse;
|
||||
@@ -1428,6 +1482,116 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Typed-slot HintMode (Phase D + HintMode dispatch) ----
|
||||
|
||||
use crate::dsl::walker::hint_mode_at_input_with_schema;
|
||||
|
||||
#[test]
|
||||
fn typed_hint_at_insert_first_value_position_for_int_column() {
|
||||
let schema = schema_with(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
match hint_mode_at_input_with_schema("insert into Customers values (", &schema) {
|
||||
Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
|
||||
other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_at_insert_second_value_position_for_text_column() {
|
||||
let schema = schema_with(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
match hint_mode_at_input_with_schema("insert into Customers values (1, ", &schema) {
|
||||
Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
|
||||
other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_at_update_set_value_uses_column_type() {
|
||||
let schema = schema_with(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text)],
|
||||
);
|
||||
match hint_mode_at_input_with_schema("update Customers set Email=", &schema) {
|
||||
Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
|
||||
other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_at_update_set_value_for_int_column() {
|
||||
let schema = schema_with(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Score", Type::Int)],
|
||||
);
|
||||
match hint_mode_at_input_with_schema("update Customers set Score=", &schema) {
|
||||
Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
|
||||
other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_at_where_value_uses_column_type() {
|
||||
let schema = schema_with("Events", &[("ts", Type::DateTime)]);
|
||||
match hint_mode_at_input_with_schema("delete from Events where ts=", &schema) {
|
||||
Some(HintMode::ProseOnly("hint.value_slot_datetime")) => {}
|
||||
other => panic!("expected ProseOnly value_slot_datetime, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_falls_back_to_generic_when_schema_missing() {
|
||||
// Empty schema: walker can't resolve column types.
|
||||
let schema = SchemaCache::default();
|
||||
match hint_mode_at_input_with_schema("insert into T values (", &schema) {
|
||||
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
|
||||
other => panic!("expected generic ProseOnly, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_not_emitted_after_complete_value() {
|
||||
// `insert into T values (1` — the int slot just MATCHED
|
||||
// (`1` is a valid int). Pending_value_type was cleared on
|
||||
// the successful match. No hint at this position
|
||||
// (between values).
|
||||
let schema = schema_with("T", &[("id", Type::Int)]);
|
||||
// Walker is now waiting for `,` or `)`. No HintMode.
|
||||
let mode = hint_mode_at_input_with_schema("insert into T values (1", &schema);
|
||||
// The current position isn't a typed slot; expected is
|
||||
// `,` / `)`. No HintMode fires.
|
||||
assert!(mode.is_none(), "got {mode:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_hint_for_each_type_routes_to_correct_catalog_key() {
|
||||
// Confirm each Type maps to its expected catalog key
|
||||
// via insert at a single-column table.
|
||||
for (ty, key) in [
|
||||
(Type::Int, "hint.value_slot_int"),
|
||||
(Type::Real, "hint.value_slot_real"),
|
||||
(Type::Decimal, "hint.value_slot_decimal"),
|
||||
(Type::Bool, "hint.value_slot_bool"),
|
||||
(Type::Text, "hint.value_slot_text"),
|
||||
(Type::Date, "hint.value_slot_date"),
|
||||
(Type::DateTime, "hint.value_slot_datetime"),
|
||||
(Type::Blob, "hint.value_slot_blob"),
|
||||
(Type::Serial, "hint.value_slot_serial"),
|
||||
(Type::ShortId, "hint.value_slot_shortid"),
|
||||
] {
|
||||
let schema = schema_with("T", &[("c", ty)]);
|
||||
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
|
||||
assert!(
|
||||
matches!(mode, Some(HintMode::ProseOnly(k)) if k == key),
|
||||
"expected ProseOnly({key}) for type {ty:?}, got {mode:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_d_update_multi_assignment_uses_per_column_types() {
|
||||
let schema = schema_with(
|
||||
|
||||
Reference in New Issue
Block a user