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:
claude@clouddev1
2026-05-15 18:05:38 +00:00
parent 124c1d33e9
commit 82955679ca
8 changed files with 416 additions and 48 deletions
+194 -30
View File
@@ -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(