diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index b780066..89c6424 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -9,7 +9,8 @@ use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue}; use crate::dsl::grammar::{ - CommandNode, IdentSource, IdentValidator, Node, ValidationError, Word, + CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, + Word, }; use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; @@ -43,17 +44,23 @@ const UNKNOWN_MESSAGES_VALIDATOR: IdentValidator = validate_unknown_messages; const SAVE_AS_WORD: Node = Node::Word(Word::keyword("as")); +const IMPORT_TARGET_IDENT: Node = Node::Ident { + source: IdentSource::NewName, + role: "target", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, +}; +const IMPORT_TARGET: Node = Node::Hinted { + mode: HintMode::ForceProse("hint.ambient_typing_name"), + inner: &IMPORT_TARGET_IDENT, +}; + const IMPORT_AS_TARGET: Node = Node::Seq(&[ Node::Word(Word::keyword("as")), - Node::Ident { - source: IdentSource::NewName, - role: "target", - validator: None, - highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, - }, + IMPORT_TARGET, ]); const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET); diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index ab8a42e..854f339 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -18,7 +18,7 @@ use crate::dsl::command::{Command, RowFilter}; use crate::dsl::grammar::{ - CommandNode, IdentSource, Node, ValidationError, Word, + CommandNode, HintMode, IdentSource, Node, ValidationError, Word, shared::{column_value_list, current_column_value}, }; use crate::dsl::value::Value; @@ -61,7 +61,15 @@ const VALUE_LITERAL_CHOICES: &[Node] = &[ Node::NumberLit { validator: None }, Node::StringLit, ]; -const VALUE_LITERAL: Node = Node::Choice(VALUE_LITERAL_CHOICES); +const VALUE_LITERAL_INNER: Node = Node::Choice(VALUE_LITERAL_CHOICES); +/// Value-literal slot with the `ProseOnly` HintMode +/// (ADR-0024 §HintMode-per-node) — the hint resolver surfaces +/// the generic "Type a value: …" prose rather than the +/// misleading `null`/`true`/`false` candidate trio. +const VALUE_LITERAL: Node = Node::Hinted { + mode: HintMode::ProseOnly("hint.value_literal_slot"), + inner: &VALUE_LITERAL_INNER, +}; // ================================================================= // show — `show (data|table) ` diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 8eaf699..3c06e56 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -14,9 +14,15 @@ use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ChangeColumnMode, ColumnSpec, Command, RelationshipSelector}; use crate::dsl::grammar::{ - CommandNode, IdentSource, Node, ValidationError, Word, + CommandNode, HintMode, IdentSource, Node, ValidationError, Word, shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR}, }; + +/// `HintMode` annotation shared by every `NewName` ident slot: +/// the user is inventing a name, so the hint panel forces the +/// "Type a name [then …]" prose rather than offering schema +/// candidates (ADR-0024 §HintMode-per-node). +const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name"); use crate::dsl::types::Type; use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; @@ -24,7 +30,7 @@ use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; // Building blocks // ================================================================= -const TABLE_NAME_NEW: Node = Node::Ident { +const TABLE_NAME_NEW_IDENT: Node = Node::Ident { source: IdentSource::NewName, role: "table_name", validator: None, @@ -33,6 +39,10 @@ const TABLE_NAME_NEW: Node = Node::Ident { writes_column: false, writes_user_listed_column: false, }; +const TABLE_NAME_NEW: Node = Node::Hinted { + mode: NEW_NAME_HINT, + inner: &TABLE_NAME_NEW_IDENT, +}; // `writes_table: true` so that the column-name slots that // follow the table name in `drop column` / `rename column` / @@ -61,7 +71,7 @@ const COLUMN_NAME: Node = Node::Ident { writes_user_listed_column: false, }; -const COLUMN_NAME_NEW: Node = Node::Ident { +const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { source: IdentSource::NewName, role: "column_name", validator: None, @@ -70,6 +80,10 @@ const COLUMN_NAME_NEW: Node = Node::Ident { writes_column: false, writes_user_listed_column: false, }; +const COLUMN_NAME_NEW: Node = Node::Hinted { + mode: NEW_NAME_HINT, + inner: &COLUMN_NAME_NEW_IDENT, +}; const RELATIONSHIP_NAME: Node = Node::Ident { source: IdentSource::Relationships, @@ -81,7 +95,7 @@ const RELATIONSHIP_NAME: Node = Node::Ident { writes_user_listed_column: false, }; -const RELATIONSHIP_NAME_NEW: Node = Node::Ident { +const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { source: IdentSource::NewName, role: "relationship_name", validator: None, @@ -90,6 +104,10 @@ const RELATIONSHIP_NAME_NEW: Node = Node::Ident { writes_column: false, writes_user_listed_column: false, }; +const RELATIONSHIP_NAME_NEW: Node = Node::Hinted { + mode: NEW_NAME_HINT, + inner: &RELATIONSHIP_NAME_NEW_IDENT, +}; // `[to]` and `[table]` connectives. const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to"))); @@ -308,6 +326,20 @@ const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES); // rename_column — `rename column [in] [table] : to ` // ================================================================= +const NEW_COLUMN_NAME_IDENT: Node = Node::Ident { + source: IdentSource::NewName, + role: "new_column_name", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, +}; +const NEW_COLUMN_NAME: Node = Node::Hinted { + mode: NEW_NAME_HINT, + inner: &NEW_COLUMN_NAME_IDENT, +}; + const RENAME_COLUMN_NODES: &[Node] = &[ Node::Word(Word::keyword("column")), IN_OPT, @@ -316,15 +348,7 @@ const RENAME_COLUMN_NODES: &[Node] = &[ Node::Punct(':'), COLUMN_NAME, Node::Word(Word::keyword("to")), - Node::Ident { - source: IdentSource::NewName, - role: "new_column_name", - validator: None, - highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, - }, + NEW_COLUMN_NAME, ]; const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES); @@ -650,16 +674,22 @@ pub static CHANGE: CommandNode = CommandNode { // (Phase C) // ================================================================= +const COL_NAME_IDENT: Node = Node::Ident { + source: IdentSource::NewName, + role: "col_name", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, +}; +const COL_NAME: Node = Node::Hinted { + mode: NEW_NAME_HINT, + inner: &COL_NAME_IDENT, +}; + const COL_SPEC_NODES: &[Node] = &[ - Node::Ident { - source: IdentSource::NewName, - role: "col_name", - validator: None, - highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, - }, + COL_NAME, Node::Punct(':'), Node::Ident { source: IdentSource::Types, diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index b251641..6d63ffa 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -308,6 +308,24 @@ pub enum Node { column_name: Option<&'static str>, inner: &'static Self, }, + /// Annotates `inner` with a hint-panel `HintMode` (ADR-0024 + /// §HintMode-per-node). On entry the walker records `mode` + /// in `WalkContext::pending_hint_mode`; on a successful + /// inner match the record clears (so positions past the + /// slot don't carry stale hint state). Transparent to + /// matching, highlighting and the expected-set otherwise — + /// it walks `inner` and returns its result verbatim. + /// + /// This is the node-attached replacement for the hint + /// resolver's earlier signature-matching: the grammar tree + /// declares the hint mode at the slot, the walker + /// propagates it, the resolver reads it. Used by the + /// value-literal fallback slot (`ProseOnly`) and `NewName` + /// ident slots (`ForceProse`). + Hinted { + mode: HintMode, + 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 4c6d185..424aeec 100644 --- a/src/dsl/grammar/shared.rs +++ b/src/dsl/grammar/shared.rs @@ -7,7 +7,8 @@ use crate::completion::TableColumn; use crate::dsl::grammar::{ - IdentSource, IdentValidator, Node, NumberValidator, ValidationError, Word, + HintMode, IdentSource, IdentValidator, Node, NumberValidator, + ValidationError, Word, }; use crate::dsl::types::Type; use crate::dsl::walker::context::WalkContext; @@ -347,7 +348,16 @@ const FALLBACK_VALUE_LITERAL_CHOICES: &[Node] = &[ Node::NumberLit { validator: None }, Node::StringLit, ]; -const FALLBACK_VALUE_LITERAL: Node = Node::Choice(FALLBACK_VALUE_LITERAL_CHOICES); +const FALLBACK_VALUE_LITERAL_INNER: Node = Node::Choice(FALLBACK_VALUE_LITERAL_CHOICES); +/// The schemaless value-literal slot. The `ProseOnly` HintMode +/// (ADR-0024 §HintMode-per-node) tells the hint resolver to +/// surface the generic "Type a value: number, 'text', …" prose +/// here rather than the misleading `null`/`true`/`false` +/// candidate trio. +const FALLBACK_VALUE_LITERAL: Node = Node::Hinted { + mode: HintMode::ProseOnly("hint.value_literal_slot"), + inner: &FALLBACK_VALUE_LITERAL_INNER, +}; const FALLBACK_VALUE_LIST: Node = Node::Repeated { inner: &FALLBACK_VALUE_LITERAL, @@ -434,10 +444,3 @@ pub fn column_value_list(ctx: &WalkContext) -> Node { } Node::Seq(Box::leak(children.into_boxed_slice())) } - -// The HintMode / NumberValidator imports are part of the Phase D -// typed-slot toolkit even though only NumberValidator is used by -// the explicit validators above; surface HintMode so future -// per-type prose annotations can attach without re-importing. -#[allow(dead_code)] -const _USES_HINT_MODE: Option = None; diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index bdcabfb..70e12a3 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -54,6 +54,13 @@ pub struct WalkContext<'a> { /// Cleared on successful inner match alongside /// `pending_value_type`. pub pending_value_column: Option, + /// The hint-panel `HintMode` declared by the grammar node + /// the walker is currently inside (ADR-0024 + /// §HintMode-per-node). Set on entry to a `Node::Hinted` + /// wrapper, cleared on successful inner match. The hint + /// resolver reads this directly instead of inferring the + /// slot kind from the shape of the expected set. + pub pending_hint_mode: Option, /// The columns the user explicitly listed in /// `insert into (col1, col2, …) values (…)` (Form A), /// in declaration order. @@ -91,6 +98,7 @@ impl<'a> WalkContext<'a> { current_column: None, pending_value_type: None, pending_value_column: None, + pending_hint_mode: None, user_listed_columns: None, } } diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index 7b0fa06..ecada4b 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -84,6 +84,28 @@ pub fn walk_node( per_byte: &mut Vec, ) -> NodeWalkResult { let pos = skip_whitespace(source, position); + let result = walk_node_inner(source, pos, node, ctx, path, per_byte); + // ADR-0024 §HintMode-per-node: `pending_hint_mode` records + // the Hinted slot the cursor is currently inside. Any + // successful match means the cursor advanced past whatever + // slot was pending — clear it. This also undoes the leak + // where a failed `Hinted` branch of a `Choice` sets the + // mode and the `Choice` then matches via a different + // branch: that branch's match clears the stale mode. + if matches!(result, NodeWalkResult::Matched { .. }) { + ctx.pending_hint_mode = None; + } + result +} + +fn walk_node_inner( + source: &str, + pos: usize, + node: &Node, + ctx: &mut WalkContext, + path: &mut MatchedPath, + per_byte: &mut Vec, +) -> NodeWalkResult { match node { Node::Word(word) => walk_word(source, pos, word, path, per_byte), Node::Punct(ch) => walk_punct(source, pos, *ch, path, per_byte), @@ -156,6 +178,16 @@ pub fn walk_node( } result } + Node::Hinted { mode, inner } => { + // ADR-0024 §HintMode-per-node. Record the grammar's + // declared hint mode so the hint resolver can read + // it directly. The `walk_node` wrapper clears it on + // any successful match (the cursor moved past the + // slot), so a Hinted slot whose inner fails at EOF + // leaves the mode set for the resolver to read. + ctx.pending_hint_mode = Some(*mode); + walk_node(source, pos, inner, ctx, path, per_byte) + } 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 5c4e6c5..38dd3aa 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -95,15 +95,19 @@ pub fn hint_resolution_at_input( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> Option { - use crate::dsl::grammar::{HintMode, IdentSource}; - use crate::dsl::walker::outcome::Expectation; + use crate::dsl::grammar::HintMode; let snap = expected_for_hint_snapshot(source, schema); + // Empty expected set means the command is already complete + // (`WalkOutcome::Match`) — no slot to hint at. if snap.expected.is_empty() { return None; } - let expected = snap.expected; + // Typed value slot: the walker tagged `pending_value_type` + // on entry to a `Node::TypedValueSlot`. Per-column-type + // prose, narrowed by the column's user-facing type, plus + // the Form B auto-gen pedagogical note. if let Some(ty) = snap.pending_value_type { return Some(HintResolution { mode: HintMode::ProseOnly(catalog_key_for_value_type(ty)), @@ -117,42 +121,23 @@ pub fn hint_resolution_at_input( }); } - let has_word = |w: &str| { - expected - .iter() - .any(|e| matches!(e, Expectation::Word(x) if *x == w)) - }; - let value_literal_slot = has_word("null") - && has_word("true") - && has_word("false") - && expected.iter().any(|e| matches!(e, Expectation::NumberLit)) - && expected.iter().any(|e| matches!(e, Expectation::StringLit)); - if value_literal_slot { - return Some(HintResolution { - mode: HintMode::ProseOnly("hint.value_literal_slot"), - column: None, - form_b_autogen_skipped: Vec::new(), - }); + // Node-attached HintMode (ADR-0024 §HintMode-per-node): the + // grammar declares the mode at the slot via `Node::Hinted`; + // the walker recorded it in `pending_hint_mode`. The hint + // resolver reads it directly — no signature-matching on the + // shape of the expected set. `ProseOnly` covers the + // value-literal fallback slot; `ForceProse` covers `NewName` + // ident slots ("Type a name"). + match snap.pending_hint_mode { + Some(mode @ (HintMode::ProseOnly(_) | HintMode::ForceProse(_))) => { + Some(HintResolution { + mode, + column: None, + form_b_autogen_skipped: Vec::new(), + }) + } + Some(HintMode::SuppressProse | HintMode::Default) | None => None, } - - let new_name_slot = expected.iter().any(|e| { - matches!( - e, - Expectation::Ident { - source: IdentSource::NewName, - .. - } - ) - }); - if new_name_slot { - return Some(HintResolution { - mode: HintMode::ForceProse("hint.ambient_typing_name"), - column: None, - form_b_autogen_skipped: Vec::new(), - }); - } - - None } /// Auto-generated columns a Form B insert skips from its value @@ -206,68 +191,12 @@ 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; - - // Hint mode is only meaningful at *required* slot positions - // (Incomplete / Mismatch outcomes). For complete commands - // (Match), `tail_expected` may carry optional-suffix - // expectations — completion surfaces those as Tab - // 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, 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 - .iter() - .any(|e| matches!(e, Expectation::Word(x) if *x == w)) - }; - let value_literal_slot = has_word("null") - && has_word("true") - && has_word("false") - && expected.iter().any(|e| matches!(e, Expectation::NumberLit)) - && expected.iter().any(|e| matches!(e, Expectation::StringLit)); - if value_literal_slot { - // 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")); - } - - // NewName ident slot: user invents a name. - let new_name_slot = expected.iter().any(|e| { - matches!( - e, - Expectation::Ident { - source: IdentSource::NewName, - .. - } - ) - }); - if new_name_slot { - // The "Type a name" prose key is selected by the - // ambient_hint dispatch — the `ForceProse` key here is - // a stable identifier the resolver maps to one of the - // two variants (`hint.ambient_typing_name` / - // `hint.ambient_typing_name_then`) depending on whether - // a next-token probe yields content. - return Some(HintMode::ForceProse("hint.ambient_typing_name")); - } - - None + // Single source of truth: `hint_resolution_at_input` already + // resolves the slot's HintMode (typed-value-slot per-type + // prose, or the node-attached `Node::Hinted` annotation). + // This thin wrapper just drops the resolution's column / + // skip detail for callers that only need the mode. + hint_resolution_at_input(source, schema).map(|r| r.mode) } const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str { @@ -421,6 +350,9 @@ struct HintWalkSnapshot { expected: Vec, pending_value_type: Option, pending_value_column: Option, + /// The grammar-declared `HintMode` at the cursor's slot + /// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node). + pending_hint_mode: Option, current_table_columns: Option>, /// `Some` when the input used Form A's explicit column list. /// `None` for Form B (`insert into T values …`) and for @@ -428,14 +360,6 @@ struct HintWalkSnapshot { user_listed_columns: Option>, } -fn expected_for_hint_with_ctx( - source: &str, - schema: Option<&crate::completion::SchemaCache>, -) -> (Vec, Option) { - let snap = expected_for_hint_snapshot(source, schema); - (snap.expected, snap.pending_value_type) -} - fn expected_for_hint_snapshot( source: &str, schema: Option<&crate::completion::SchemaCache>, @@ -449,27 +373,24 @@ fn expected_for_hint_snapshot( .collect() }; + let empty_snapshot = || HintWalkSnapshot { + expected: entry_words(), + pending_value_type: None, + pending_value_column: None, + pending_hint_mode: None, + current_table_columns: None, + user_listed_columns: None, + }; + if source.trim().is_empty() { - return HintWalkSnapshot { - expected: entry_words(), - pending_value_type: None, - pending_value_column: None, - current_table_columns: None, - user_listed_columns: None, - }; + return empty_snapshot(); } 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 HintWalkSnapshot { - expected: entry_words(), - pending_value_type: None, - pending_value_column: None, - current_table_columns: None, - user_listed_columns: None, - }; + return empty_snapshot(); }; let expected = match result.outcome { outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => { @@ -482,6 +403,7 @@ fn expected_for_hint_snapshot( expected, pending_value_type: ctx.pending_value_type, pending_value_column: ctx.pending_value_column, + pending_hint_mode: ctx.pending_hint_mode, current_table_columns: ctx.current_table_columns, user_listed_columns: ctx.user_listed_columns, }