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:
@@ -422,6 +422,36 @@ fn build_insert(path: &MatchedPath) -> Result<Command, ValidationError> {
|
|||||||
// Form C: the first paren contained the value list. The
|
// Form C: the first paren contained the value list. The
|
||||||
// Repeated tagged the matched values via their natural
|
// Repeated tagged the matched values via their natural
|
||||||
// MatchedKind (Word/NumberLit/StringLit); collect them.
|
// 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)?;
|
let values = collect_values_in_parens(path, first_paren_idx)?;
|
||||||
Ok(Command::Insert {
|
Ok(Command::Insert {
|
||||||
table,
|
table,
|
||||||
|
|||||||
@@ -497,6 +497,12 @@ fn walk_repeated(
|
|||||||
loop {
|
loop {
|
||||||
let saved_path_len = path.items.len();
|
let saved_path_len = path.items.len();
|
||||||
let saved_byte_len = per_byte.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 {
|
let result = if count == 0 {
|
||||||
walk_node(source, cur, inner, ctx, path, per_byte)
|
walk_node(source, cur, inner, ctx, path, per_byte)
|
||||||
} else if let Some(sep) = separator {
|
} else if let Some(sep) = separator {
|
||||||
@@ -504,6 +510,7 @@ fn walk_repeated(
|
|||||||
let sep_saved_byte = per_byte.len();
|
let sep_saved_byte = per_byte.len();
|
||||||
match walk_node(source, cur, sep, ctx, path, per_byte) {
|
match walk_node(source, cur, sep, ctx, path, per_byte) {
|
||||||
NodeWalkResult::Matched { end, .. } => {
|
NodeWalkResult::Matched { end, .. } => {
|
||||||
|
sep_consumed_to = Some(end);
|
||||||
walk_node(source, end, inner, ctx, path, per_byte)
|
walk_node(source, end, inner, ctx, path, per_byte)
|
||||||
}
|
}
|
||||||
NodeWalkResult::NoMatch { .. } => {
|
NodeWalkResult::NoMatch { .. } => {
|
||||||
@@ -521,7 +528,31 @@ fn walk_repeated(
|
|||||||
cur = end;
|
cur = end;
|
||||||
count += 1;
|
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);
|
path.items.truncate(saved_path_len);
|
||||||
per_byte.truncate(saved_byte_len);
|
per_byte.truncate(saved_byte_len);
|
||||||
last_expected = Some(expected);
|
last_expected = Some(expected);
|
||||||
|
|||||||
+16
-2
@@ -271,7 +271,14 @@ pub fn completion_probe(
|
|||||||
outcome::WalkOutcome::Match { .. } => result.tail_expected,
|
outcome::WalkOutcome::Match { .. } => result.tail_expected,
|
||||||
outcome::WalkOutcome::Incomplete { expected, .. }
|
outcome::WalkOutcome::Incomplete { expected, .. }
|
||||||
| outcome::WalkOutcome::Mismatch { expected, .. } => 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 {
|
CompletionProbe {
|
||||||
expected,
|
expected,
|
||||||
@@ -324,7 +331,14 @@ pub fn expected_at_input(source: &str) -> Vec<outcome::Expectation> {
|
|||||||
outcome::WalkOutcome::Match { .. } => result.tail_expected,
|
outcome::WalkOutcome::Match { .. } => result.tail_expected,
|
||||||
outcome::WalkOutcome::Incomplete { expected, .. }
|
outcome::WalkOutcome::Incomplete { expected, .. }
|
||||||
| outcome::WalkOutcome::Mismatch { expected, .. } => 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
||||||
("parse.custom.change_column_flags_exclusive", &[]),
|
("parse.custom.change_column_flags_exclusive", &[]),
|
||||||
("parse.custom.create_table_needs_pk", &[]),
|
("parse.custom.create_table_needs_pk", &[]),
|
||||||
|
("parse.custom.insert_form_a_missing_values", &["columns"]),
|
||||||
("parse.custom.on_action_specified_twice", &["target"]),
|
("parse.custom.on_action_specified_twice", &["target"]),
|
||||||
("parse.custom.replay_path_expected", &[]),
|
("parse.custom.replay_path_expected", &[]),
|
||||||
("parse.custom.unknown_action", &["found", "expected"]),
|
("parse.custom.unknown_action", &["found", "expected"]),
|
||||||
|
|||||||
@@ -342,6 +342,13 @@ parse:
|
|||||||
# is the literal text; `{expected}` names the required
|
# is the literal text; `{expected}` names the required
|
||||||
# shape (`integer`, `number`, …).
|
# shape (`integer`, `number`, …).
|
||||||
bind_type_mismatch: "value '{found}' is not a valid {expected}"
|
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
|
# Caret pointer showing where in the input the parser
|
||||||
# failed. `{padding}` is the leading whitespace; the
|
# failed. `{padding}` is the leading whitespace; the
|
||||||
# template appends `^` so the rendered line places the
|
# template appends `^` so the rendered line places the
|
||||||
|
|||||||
Reference in New Issue
Block a user