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.
This commit is contained in:
claude@clouddev1
2026-05-15 20:06:52 +00:00
parent 3b1955c6bf
commit 0b15ce0306
5 changed files with 86 additions and 3 deletions
+30
View File
@@ -422,6 +422,36 @@ fn build_insert(path: &MatchedPath) -> Result<Command, ValidationError> {
// 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<String> = 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,
+32 -1
View File
@@ -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<usize> = 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);
+16 -2
View File
@@ -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::Expectation> {
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,
}
}