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
+102 -1
View File
@@ -189,7 +189,10 @@ pub fn ambient_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);
// ADR-0024 §Phase D §typed-value-slots: pass the schema so
// the resolver can narrow value-slot prose per column type
// (Date → "Type a date as 'YYYY-MM-DD'", etc.).
let hint_mode = crate::dsl::walker::hint_mode_at_input_with_schema(leading, cache);
match hint_mode {
Some(crate::dsl::grammar::HintMode::ProseOnly(key)) => {
// The cursor sits at a slot where Tab candidates
@@ -577,6 +580,104 @@ mod tests {
assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none());
}
// ---- Phase D typed-slot hints (end-to-end) -------
fn schema_with_columns(
table: &str,
cols: &[(&str, crate::dsl::types::Type)],
) -> crate::completion::SchemaCache {
use crate::completion::{SchemaCache, TableColumn};
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let columns: Vec<TableColumn> = cols
.iter()
.map(|(n, t)| TableColumn {
name: (*n).to_string(),
user_type: *t,
})
.collect();
for c in &columns {
cache.columns.push(c.name.clone());
}
cache
.table_columns
.insert(table.to_string(), columns);
cache
}
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("quoted string"),
"expected text-slot prose, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("YYYY-MM-DD"),
"expected date-slot prose, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_at_value_slot_falls_back_to_generic_without_schema() {
// Empty cache: the walker can't resolve the column type
// → falls back to the generic value-literal prose.
let cache = empty_cache();
let input = "insert into T values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
// Generic prose lists all forms.
assert!(p.contains("number"), "got: {p:?}");
assert!(p.contains("true/false") || p.contains("true"), "got: {p:?}");
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_for_valid_input_invites_submit() {
let h = prose("create table T with pk", 22).expect("prose hint");