From 0b15ce030603dd15919b224827ae588d68c76e2a Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 15 May 2026 20:06:52 +0000 Subject: [PATCH] Walker + parser: surface mid-typing after separators and Form C/A ambiguity The typing-surface matrix exposed two bugs the existing 859-test suite missed: walk_repeated: when the separator consumed but the inner item failed at EOF, the old path rolled the separator back and reported a definite error at the rollback position (`insert into T (a, ` flashed red on the `,` after each comma). Now propagates Incomplete with the inner's expected set so the input renderer treats it as mid-typing. build_insert Form C path: `insert into T (col)` walked to a complete match but produced `values: []` because Form C's value collector drops ident-shaped items. The user almost certainly meant Form A and just hasn't typed `values (...)` yet. Reject with a ValidationError naming the Form-A continuation; classify_input now reports IncompleteAtEof. completion_probe / expected_at_input: ValidationFailed used to return an empty expected set, leaving Tab with nothing to offer at the new Form-A flag point. Now surface result.tail_expected (skipped-Optional expectations captured before validation fired) so `values` is still offered as a candidate. --- src/dsl/grammar/data.rs | 30 ++++++++++++++++++++++++++++++ src/dsl/walker/driver.rs | 33 ++++++++++++++++++++++++++++++++- src/dsl/walker/mod.rs | 18 ++++++++++++++++-- src/friendly/keys.rs | 1 + src/friendly/strings/en-US.yaml | 7 +++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 0439063..ab8a42e 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -422,6 +422,36 @@ fn build_insert(path: &MatchedPath) -> Result { // Form C: the first paren contained the value list. The // Repeated tagged the matched values via their natural // MatchedKind (Word/NumberLit/StringLit); collect them. + // + // Form-A-without-`values` recovery: the shared + // INSERT_PAREN_ITEM choice accepts both VALUE_LITERAL + // and Ident{Columns} so that Form A can resolve + // column-name items inside its `( cols )` list. When the + // user types `insert into T (col)` (column-shaped item, + // no `values` keyword), the grammar walks to a complete + // match but the user almost certainly meant Form A and + // forgot the `values (...)` suffix. Reject here with a + // ValidationError — the walker classifies validation + // errors as `at_eof: true`, so the input renderer + // surfaces this as IncompleteAtEof (mid-typing) rather + // than dispatching a logically-broken Form C insert with + // an empty value list. + let user_listed_columns: Vec = path + .items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Ident { + role: "insert_first_item", + } => Some(i.text.clone()), + _ => None, + }) + .collect(); + if !user_listed_columns.is_empty() { + return Err(ValidationError { + message_key: "parse.custom.insert_form_a_missing_values", + args: vec![("columns", user_listed_columns.join(", "))], + }); + } let values = collect_values_in_parens(path, first_paren_idx)?; Ok(Command::Insert { table, diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index fc603ce..7b0fa06 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -497,6 +497,12 @@ fn walk_repeated( loop { let saved_path_len = path.items.len(); let saved_byte_len = per_byte.len(); + // Track whether the separator successfully consumed + // before the inner attempt. Used below to distinguish + // "user typed `,` then stopped at EOF — mid-typing the + // next item" from "list naturally ended at the inner + // boundary". + let mut sep_consumed_to: Option = None; let result = if count == 0 { walk_node(source, cur, inner, ctx, path, per_byte) } else if let Some(sep) = separator { @@ -504,6 +510,7 @@ fn walk_repeated( let sep_saved_byte = per_byte.len(); match walk_node(source, cur, sep, ctx, path, per_byte) { NodeWalkResult::Matched { end, .. } => { + sep_consumed_to = Some(end); walk_node(source, end, inner, ctx, path, per_byte) } NodeWalkResult::NoMatch { .. } => { @@ -521,7 +528,31 @@ fn walk_repeated( cur = end; count += 1; } - NodeWalkResult::NoMatch { expected, .. } => { + NodeWalkResult::NoMatch { expected, position: inner_pos } => { + // Mid-typing-the-next-item recovery: if the + // separator just consumed and the inner failed + // at EOF, the user is partway through typing the + // next item — propagate as Incomplete so the + // outer walker classifies the input as + // mid-typing rather than rolling the separator + // back and producing a structural Mismatch at + // the separator position. + // + // Without this branch, `insert into T (a, ` at + // EOF would roll back the `,`, then the outer + // `(`-list expected `)` at `cur`, see the + // separator instead, and report a definite + // error at the separator. Real users hit this + // every time they type a comma and pause. + if let Some(post_sep) = sep_consumed_to { + let post_ws = skip_whitespace(source, post_sep); + if post_ws >= source.len() { + return NodeWalkResult::Incomplete { + position: inner_pos, + expected, + }; + } + } path.items.truncate(saved_path_len); per_byte.truncate(saved_byte_len); last_expected = Some(expected); diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 02209ed..7409962 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -271,7 +271,14 @@ pub fn completion_probe( outcome::WalkOutcome::Match { .. } => result.tail_expected, outcome::WalkOutcome::Incomplete { expected, .. } | outcome::WalkOutcome::Mismatch { expected, .. } => expected, - outcome::WalkOutcome::ValidationFailed { .. } => Vec::new(), + // Validation failure path: the walker matched the + // structural shape but the AST builder rejected (e.g. + // Form C with column-shaped items). The walker still + // captured the skipped-Optional expectations before the + // validation fired — surface those so the user gets + // useful Tab candidates even at a validation-flagged + // position. + outcome::WalkOutcome::ValidationFailed { .. } => result.tail_expected, }; CompletionProbe { expected, @@ -324,7 +331,14 @@ pub fn expected_at_input(source: &str) -> Vec { outcome::WalkOutcome::Match { .. } => result.tail_expected, outcome::WalkOutcome::Incomplete { expected, .. } | outcome::WalkOutcome::Mismatch { expected, .. } => expected, - outcome::WalkOutcome::ValidationFailed { .. } => Vec::new(), + // Validation failure path: the walker matched the + // structural shape but the AST builder rejected (e.g. + // Form C with column-shaped items). The walker still + // captured the skipped-Optional expectations before the + // validation fired — surface those so the user gets + // useful Tab candidates even at a validation-flagged + // position. + outcome::WalkOutcome::ValidationFailed { .. } => result.tail_expected, } } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 91c698a..aa7b460 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -158,6 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.custom.bind_type_mismatch", &["found", "expected"]), ("parse.custom.change_column_flags_exclusive", &[]), ("parse.custom.create_table_needs_pk", &[]), + ("parse.custom.insert_form_a_missing_values", &["columns"]), ("parse.custom.on_action_specified_twice", &["target"]), ("parse.custom.replay_path_expected", &[]), ("parse.custom.unknown_action", &["found", "expected"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 4237b4d..1a5223e 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -342,6 +342,13 @@ parse: # is the literal text; `{expected}` names the required # shape (`integer`, `number`, …). bind_type_mismatch: "value '{found}' is not a valid {expected}" + # `insert into T (col)` with no `values (...)` afterwards + # — the shared `(…)` opener matched as a Form-A column + # list but the user hasn't typed the value clause yet. + # Surfaced as a parse-time error so the input renderer + # classifies the input as mid-typing rather than + # dispatching a logically-empty Form C insert. + insert_form_a_missing_values: "`insert into ...({columns})` looks like Form A — add `values (...)` to supply the matching values." # Caret pointer showing where in the input the parser # failed. `{padding}` is the leading whitespace; the # template appends `^` so the rendered line places the