From 82955679cac5bdaa7592ba16ad1a4c402008b204 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 15 May 2026 18:05:38 +0000 Subject: [PATCH] ADR-0024 Phase D: per-column-type hint prose at value slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` is the new public entry point. Reads `pending_value_type` from the walker's WalkContext and emits `HintMode::ProseOnly("hint.value_slot_")` — 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_` 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. --- src/dsl/grammar/mod.rs | 14 ++ src/dsl/grammar/shared.rs | 70 ++++++++-- src/dsl/walker/context.rs | 8 ++ src/dsl/walker/driver.rs | 14 ++ src/dsl/walker/mod.rs | 224 +++++++++++++++++++++++++++----- src/friendly/keys.rs | 11 ++ src/friendly/strings/en-US.yaml | 20 ++- src/input_render.rs | 103 ++++++++++++++- 8 files changed, 416 insertions(+), 48 deletions(-) diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 8ed9650..352044c 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -281,6 +281,20 @@ pub enum Node { /// Phase D+ uses this for `column_value_list`. #[allow(dead_code)] DynamicSubgrammar(fn(&WalkContext) -> Self), + /// Typed value-literal slot (ADR-0024 §Phase D §typed-value-slots). + /// + /// Walks `inner` to consume the literal but records the + /// column type in `WalkContext::pending_value_type` so the + /// hint resolver can emit per-type catalog prose ("Type an + /// integer", "Type a date as 'YYYY-MM-DD'", …) at empty + /// prefix at this slot. The recorded type clears on a + /// successful inner match — so positions BETWEEN typed + /// slots (`insert into T values (1` mid-input) don't carry + /// a stale hint type. + TypedValueSlot { + ty: crate::dsl::types::Type, + inner: &'static Self, + }, } /// Top-level entry record. One per command. The `entry` keyword diff --git a/src/dsl/grammar/shared.rs b/src/dsl/grammar/shared.rs index 7ab1350..49fe365 100644 --- a/src/dsl/grammar/shared.rs +++ b/src/dsl/grammar/shared.rs @@ -186,10 +186,22 @@ const INT_SLOT_CHOICES: &[Node] = &[ }, NULL_WORD, ]; -const INT_SLOT: Node = Node::Choice(INT_SLOT_CHOICES); +const INT_SLOT_INNER: Node = Node::Choice(INT_SLOT_CHOICES); +const INT_SLOT: Node = Node::TypedValueSlot { + ty: Type::Int, + inner: &INT_SLOT_INNER, +}; +const SERIAL_SLOT: Node = Node::TypedValueSlot { + ty: Type::Serial, + inner: &INT_SLOT_INNER, +}; const REAL_SLOT_CHOICES: &[Node] = &[Node::NumberLit { validator: None }, NULL_WORD]; -const REAL_SLOT: Node = Node::Choice(REAL_SLOT_CHOICES); +const REAL_SLOT_INNER: Node = Node::Choice(REAL_SLOT_CHOICES); +const REAL_SLOT: Node = Node::TypedValueSlot { + ty: Type::Real, + inner: &REAL_SLOT_INNER, +}; const DECIMAL_SLOT_CHOICES: &[Node] = &[ Node::NumberLit { @@ -197,26 +209,54 @@ const DECIMAL_SLOT_CHOICES: &[Node] = &[ }, NULL_WORD, ]; -const DECIMAL_SLOT: Node = Node::Choice(DECIMAL_SLOT_CHOICES); +const DECIMAL_SLOT_INNER: Node = Node::Choice(DECIMAL_SLOT_CHOICES); +const DECIMAL_SLOT: Node = Node::TypedValueSlot { + ty: Type::Decimal, + inner: &DECIMAL_SLOT_INNER, +}; const BOOL_SLOT_CHOICES: &[Node] = &[ Node::Word(Word::keyword("true")), Node::Word(Word::keyword("false")), NULL_WORD, ]; -const BOOL_SLOT: Node = Node::Choice(BOOL_SLOT_CHOICES); +const BOOL_SLOT_INNER: Node = Node::Choice(BOOL_SLOT_CHOICES); +const BOOL_SLOT: Node = Node::TypedValueSlot { + ty: Type::Bool, + inner: &BOOL_SLOT_INNER, +}; const TEXT_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; -const TEXT_SLOT: Node = Node::Choice(TEXT_SLOT_CHOICES); +const TEXT_SLOT_INNER: Node = Node::Choice(TEXT_SLOT_CHOICES); +const TEXT_SLOT: Node = Node::TypedValueSlot { + ty: Type::Text, + inner: &TEXT_SLOT_INNER, +}; -const DATE_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; -const DATE_SLOT: Node = Node::Choice(DATE_SLOT_CHOICES); - -const DATETIME_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; -const DATETIME_SLOT: Node = Node::Choice(DATETIME_SLOT_CHOICES); - -const BLOB_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; -const BLOB_SLOT: Node = Node::Choice(BLOB_SLOT_CHOICES); +// Date / datetime share the StringLit-or-null shape with text +// but get distinct catalog-prose entries so the hint surfaces +// the YYYY-MM-DD / YYYY-MM-DD HH:MM:SS format examples. +const DATE_SLOT: Node = Node::TypedValueSlot { + ty: Type::Date, + inner: &TEXT_SLOT_INNER, +}; +const DATETIME_SLOT: Node = Node::TypedValueSlot { + ty: Type::DateTime, + inner: &TEXT_SLOT_INNER, +}; +const BLOB_SLOT: Node = Node::TypedValueSlot { + ty: Type::Blob, + inner: &TEXT_SLOT_INNER, +}; +// shortid columns store base58 text (ADR-0011 fk_target_type +// shortid → text); the slot accepts a quoted-text literal or +// null. The pre-Phase-D plain-Choice scaffolding had this +// mapped to INT_SLOT (a holdover from the chumsky-side generic +// VALUE_LITERAL fallback). Per-type dispatch corrects that. +const SHORTID_SLOT: Node = Node::TypedValueSlot { + ty: Type::ShortId, + inner: &TEXT_SLOT_INNER, +}; /// Dispatch a value slot per user-facing type /// (ADR-0024 §slot_for_type). Returns the same node every time @@ -225,7 +265,9 @@ const BLOB_SLOT: Node = Node::Choice(BLOB_SLOT_CHOICES); #[must_use] pub const fn slot_for_type(ty: Type) -> Node { match ty { - Type::Int | Type::Serial | Type::ShortId => INT_SLOT, + Type::Int => INT_SLOT, + Type::Serial => SERIAL_SLOT, + Type::ShortId => SHORTID_SLOT, Type::Real => REAL_SLOT, Type::Decimal => DECIMAL_SLOT, Type::Bool => BOOL_SLOT, diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index e44f9e0..979d521 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -31,6 +31,13 @@ pub struct WalkContext<'a> { pub current_table: Option, pub current_table_columns: Option>, pub current_column: Option, + /// The column type the walker is *about* to consume a value + /// for (ADR-0024 §Phase D §typed-value-slots). Set by the + /// walker on entry to a `Node::TypedValueSlot`, cleared on + /// successful inner match. The hint resolver reads this to + /// emit per-type prose ("Type an integer", "Type a date as + /// 'YYYY-MM-DD'", …) at empty prefix at typed value slots. + pub pending_value_type: Option, } impl<'a> WalkContext<'a> { @@ -52,6 +59,7 @@ impl<'a> WalkContext<'a> { current_table: None, current_table_columns: None, current_column: None, + pending_value_type: None, } } } diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index de102ff..4528f5f 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -130,6 +130,20 @@ pub fn walk_node( let resolved: &'static Node = Box::leak(Box::new(factory(ctx))); walk_node(source, pos, resolved, ctx, path, per_byte) } + Node::TypedValueSlot { ty, inner } => { + // ADR-0024 §Phase D §typed-value-slots. Tag the + // pending column type so the hint resolver can emit + // per-type prose at empty prefix. Clear on + // successful inner match — positions BETWEEN typed + // slots (post-comma, between values) don't carry a + // stale hint type. + ctx.pending_value_type = Some(*ty); + let result = walk_node(source, pos, inner, ctx, path, per_byte); + if matches!(result, NodeWalkResult::Matched { .. }) { + ctx.pending_value_type = None; + } + result + } Node::Flag(name) => walk_flag(source, pos, name, path, per_byte), Node::Repeated { inner, diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 745cdb7..223bfbd 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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 { + 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 { + hint_mode_at_input_inner(source, Some(schema)) +} + +fn hint_mode_at_input_inner( + source: &str, + schema: Option<&crate::completion::SchemaCache>, +) -> Option { 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 // 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 && 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 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 { } } -/// 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 { +/// 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, Option) { 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( diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index e613aa3..262b9ec 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -138,6 +138,17 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ "hint.ambient_typing_name_then", &["next"], ), + // Per-column-type value-slot hints (ADR-0024 §Phase D). + ("hint.value_slot_blob", &[]), + ("hint.value_slot_bool", &[]), + ("hint.value_slot_date", &[]), + ("hint.value_slot_datetime", &[]), + ("hint.value_slot_decimal", &[]), + ("hint.value_slot_int", &[]), + ("hint.value_slot_real", &[]), + ("hint.value_slot_serial", &[]), + ("hint.value_slot_shortid", &[]), + ("hint.value_slot_text", &[]), // ---- Parse error rendering ---- ("parse.available_commands", &["commands"]), ("parse.caret", &["padding"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index a955c91..da9668c 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -285,10 +285,24 @@ hint: # Value-literal slot — `insert ... values (`, `update ... set # col=`, `where col=`. Replaces the misleading "null true # false" keyword candidate list with format guidance for all - # accepted literal forms. Schema-aware narrowing (showing only - # the relevant format for the column's type) waits on - # ADR-0023. + # accepted literal forms. Used when the walker can't resolve a + # column type (schemaless parse, missing table, unknown column). value_literal_slot: "Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')" + # Per-column-type value-slot hints (ADR-0024 §Phase D §typed-value-slots). + # Fired when the walker resolved the column's user-facing type + # at the current value slot; narrows the prose to the relevant + # literal forms for that type. Falls back to + # `value_literal_slot` when the type can't be resolved. + value_slot_int: "Type an integer (e.g. 42, -7) or null" + value_slot_real: "Type a number (e.g. 3.14, -0.5) or null" + value_slot_decimal: "Type a number (e.g. 19.95, -2.50) or null" + value_slot_bool: "Type true, false, or null" + value_slot_text: "Type a quoted string (e.g. 'Alice') or null" + value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null" + value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null" + value_slot_blob: "Type a quoted blob literal or null" + value_slot_serial: "Type an integer (or omit to auto-generate) or null" + value_slot_shortid: "Type a quoted shortid (or omit to auto-generate) or null" parse: # Wrapper around chumsky's structural error message. The diff --git a/src/input_render.rs b/src/input_render.rs index 96706df..3a8f50f 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -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 = 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");