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:
@@ -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
|
||||
|
||||
+56
-14
@@ -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,
|
||||
|
||||
@@ -31,6 +31,13 @@ pub struct WalkContext<'a> {
|
||||
pub current_table: Option<String>,
|
||||
pub current_table_columns: Option<Vec<TableColumn>>,
|
||||
pub current_column: Option<TableColumn>,
|
||||
/// 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<crate::dsl::types::Type>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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(
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
|
||||
|
||||
+102
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user