grammar+walker: 3j — shared insert/update/delete entry words (ADR-0033 §2 / Amendments 1 & 3)

Wire `insert`/`update`/`delete` as shared DSL/SQL entry words through the
category-grouped dispatcher (ADR-0033 Amendment 1): the Advanced SQL nodes
move off the dev words (`sqlinsert`/`sql_update`/`sql_delete`) to the real
keywords, registered alongside the Simple DSL nodes. Remove the dev-word
scaffold; collapse build_sql_{insert,update,delete} to source.trim();
de-duplicate the two REGISTRY entry-word listing sites.

Dispatch model (ADR-0033 Amendment 3, written this round):
- A command is the mode-rooted grammar-path outcome; identity is intrinsic.
  Advanced mode tries SQL first, falling back to the Simple DSL command when
  no SQL branch matches a token (`delete … --all-rows` falls back;
  `update … --all-rows` does not — the SET expression absorbs it, harmless
  since the engine treats `--all-rows` as a comment).
- Simple mode commits the DSL candidate for a shared word, surfacing the real
  DSL error; bare "this is SQL" is reserved for SQL-only entry words
  (`select`/`with`). A content rejection on the SQL candidate (internal
  table) is committed, never masked by the DSL fallback.

Combined DSL-error + advanced-SQL pointer (ADR-0033 Amendment 3): a Simple-mode
definite DSL error that would run as SQL in advanced mode gains the
`advanced_mode.also_valid_sql` suffix — in the live hint (ambient_hint_in_mode)
and on submit (dispatch_dsl), via the shared advanced_alternative_note — so the
actionable DSL fix and the mode pointer coexist (submit covers constructs that
surface only on submit, e.g. `delete … returning`).

Internal-table rejection symmetrised (/runda finding B, ADR-0030 §6): the DSL
data-command target slots (insert/update/delete/show data/show table) gained
reject_internal_table, so `__rdbms_*` tables are refused in Simple mode too —
previously only the advanced SQL grammar rejected them.

Mode-awareness: classify_input_with_schema_in_mode and
invalid_ident_at_cursor_in_mode stop leaking the advanced SQL view into
simple-mode hints for shared words.

Tests: dev-word inputs migrated to the real words (advanced); DSL grammar /
completion / phase-D / db tests parse in Simple mode (the DSL surface); replay
keeps its advanced-mode model (one stale assertion fixed); dispatcher routing,
combined-pointer, and internal-table tests added. Suite 1626 pass / 0 fail /
1 ignored; clippy --all-targets -D warnings clean.

Defer M4 (execution-time mode side-channel; tracked in requirements.md) to its
own ADR.
This commit is contained in:
claude@clouddev1
2026-05-23 21:13:39 +00:00
parent c16196fc7f
commit d5c7f63513
22 changed files with 956 additions and 314 deletions
+132 -2
View File
@@ -90,7 +90,9 @@ pub fn render_input_runs_in_mode(
{
overlay_error(&mut runs, pos, theme);
}
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor_byte, cache) {
if let Some(inv) =
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor_byte, cache, mode)
{
overlay_error(&mut runs, inv.range.0, theme);
}
// Schema-aware diagnostics (ADR-0027 §2): unknown table /
@@ -167,6 +169,27 @@ pub fn classify_input_with_schema(
classify_parse_result(parse_command_with_schema(input, cache))
}
/// Mode-aware [`classify_input_with_schema`].
///
/// Walks the input in `mode` so the simple-mode DSL surface is
/// classified against the DSL grammar rather than the advanced SQL
/// grammar — relevant for the shared `insert`/`update`/`delete` entry
/// words (ADR-0033 Amendment 3). The mode-less entry point keeps its
/// advanced-mode behaviour.
#[must_use]
pub fn classify_input_with_schema_in_mode(
input: &str,
cache: &crate::completion::SchemaCache,
mode: Mode,
) -> InputState {
if input.trim().is_empty() {
return InputState::Empty;
}
classify_parse_result(crate::dsl::parser::parse_command_with_schema_in_mode(
input, cache, mode,
))
}
fn classify_parse_result(
result: Result<crate::dsl::Command, ParseError>,
) -> InputState {
@@ -239,6 +262,13 @@ pub fn ambient_hint(
/// "this is SQL" gate. The simple-mode entry point [`ambient_hint`]
/// forwards here with `Mode::Simple`.
///
/// In simple mode, when the line is a *definite* DSL error but the
/// same line would parse in advanced mode, the DSL error prose is
/// suffixed with the `advanced_mode.also_valid_sql` pointer — so the
/// user keeps the actionable DSL fix *and* learns it would run as SQL
/// in advanced mode (ADR-0033 Amendment 3). Mid-typing (incomplete)
/// input is not suffixed, to avoid noise during normal DSL entry.
///
/// Returns `None` for empty input — caller falls back to
/// `panel.hint_empty`.
#[must_use]
@@ -248,6 +278,58 @@ pub fn ambient_hint_in_mode(
memo: Option<&crate::completion::LastCompletion>,
cache: &crate::completion::SchemaCache,
mode: Mode,
) -> Option<AmbientHint> {
let core = ambient_hint_core_in_mode(input, cursor, memo, cache, mode);
// Combine: a simple-mode *definite* DSL error that would run in
// advanced mode keeps its DSL prose and gains the mode pointer.
// Skip the "command complete" prose — appending the pointer to a
// "submit this" hint would be contradictory (and that prose can
// come from the hint's schemaless fallback even when the
// schema-aware classify is a definite error — a pre-existing
// quirk this combine step deliberately does not amplify).
if mode == Mode::Simple
&& let Some(AmbientHint::Prose(message)) = &core
&& *message != crate::t!("hint.ambient_complete")
&& let Some(suffix) = advanced_alternative_note(input, cache)
{
return Some(AmbientHint::Prose(format!("{message} {suffix}")));
}
core
}
/// The `advanced_mode.also_valid_sql` pointer string, or `None`.
///
/// Returns the pointer when a simple-mode line is a *definite* DSL
/// error (not merely incomplete) yet parses in advanced mode. Used to
/// combine the DSL fix with the mode hint — both while typing
/// (`ambient_hint_in_mode`) and on submit (`App::dispatch_dsl`) — so
/// the pointer reaches SQL constructs that surface only on submit,
/// e.g. `delete … returning` (ADR-0033 Amendment 3).
#[must_use]
pub fn advanced_alternative_note(
input: &str,
cache: &crate::completion::SchemaCache,
) -> Option<String> {
let definite_dsl_error = matches!(
classify_input_with_schema_in_mode(input, cache, Mode::Simple),
InputState::DefiniteErrorAt(_)
);
if !definite_dsl_error {
return None;
}
if parse_command_with_schema_in_mode(input, cache, Mode::Advanced).is_ok() {
Some(crate::t!("advanced_mode.also_valid_sql"))
} else {
None
}
}
fn ambient_hint_core_in_mode(
input: &str,
cursor: usize,
memo: Option<&crate::completion::LastCompletion>,
cache: &crate::completion::SchemaCache,
mode: Mode,
) -> Option<AmbientHint> {
if input.trim().is_empty() {
return None;
@@ -401,7 +483,8 @@ pub fn ambient_hint_in_mode(
// Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.)
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor, cache) {
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
{
let kind = match inv.source {
crate::dsl::grammar::IdentSource::Tables => "table",
crate::dsl::grammar::IdentSource::Columns => "column",
@@ -989,6 +1072,53 @@ mod tests {
}
}
#[test]
fn ambient_hint_combines_dsl_error_with_advanced_sql_pointer() {
// ADR-0033 Amendment 3: in simple mode, a *definite* DSL
// error whose line would run as SQL in advanced mode keeps
// its actionable DSL prose AND gains the
// `advanced_mode.also_valid_sql` pointer. `Customers(id
// serial, Name, Email)`: DSL Form B auto-skips the serial
// `id`, so three values is a definite DSL error — but the same
// line is a valid SQL insert (all three columns).
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
);
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
// The DSL detail survives …
assert!(p.contains("Name"), "expected DSL slot detail, got: {p:?}");
// … and the advanced-mode pointer is appended.
assert!(
p.contains("advanced mode"),
"expected the advanced-mode pointer, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_does_not_add_pointer_for_a_valid_dsl_command() {
// A valid simple-mode DSL command gets no advanced pointer —
// it isn't an error, and there is nothing to switch modes for.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
let input = "insert into Customers values ('Alice')";
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
assert!(
!p.contains("advanced mode"),
"a valid DSL command must not carry the advanced pointer, got: {p:?}",
);
}
}
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;