Insert grammar: Form C type-awareness via lookahead (ADR-0024 §Phase D)

Form C (`insert into T (vals)`) shared the `(` opener with Form A,
so its paren was an untyped Repeated(Choice(literal, ident)) — values
weren't type- or count-checked at parse time (handoff-12 §2.2).

New Node::Lookahead variant: a factory that peeks the source. The
insert first-paren factory inspects the first token — a value literal
routes the contents through the typed column_value_list (Form B
dispatch contract: per-non-auto-column typed slots); an identifier or
empty paren routes to a Form A column-name list. So Form C now gets
the same per-column typed slots, hints, and parse-time type/count
checking Form B has.

The explicit-Choice-branch split is impossible here (committed-choice
semantics commit after `(` matches); lookahead is the only route, and
DynamicSubgrammar factories couldn't see the source. Node::Lookahead
is not memoized — its output depends on source — but it returns only
a small node (a Repeated, or a thin DynamicSubgrammar wrapper that
delegates to the memoized column_value_list).

`insert into T (` now cleanly shows Form A column candidates instead
of mixed Form-A/C suggestions. Form C matrix tests updated for the
type-aware behaviour.
This commit is contained in:
claude@clouddev1
2026-05-15 22:27:53 +00:00
parent 9bbb96e735
commit 90e3f5dbfb
18 changed files with 411 additions and 262 deletions
+121 -53
View File
@@ -1,24 +1,30 @@
//! Matrix coverage for `insert into T (vals)` (Form C — bare
//! value list, no `values` keyword).
//!
//! Form C shares the `( ... )` opener with Form A but resolves
//! the paren contents as values rather than column names. Per
//! handoff-12 §2.2 the Form C path is *type-unaware* — its
//! grammar uses the schemaless `INSERT_PAREN_LIST` shape, not
//! the typed `column_value_list`. Type validation happens at
//! bind time, not parse time.
//! Form C and Form B produce the identical AST and dispatch
//! identically (`Insert { columns: None, … }`). As of the
//! Form-C type-awareness work (handoff-14), Form C's paren is
//! resolved by the `insert_first_paren` lookahead: a value
//! literal as the first token routes the contents through the
//! typed `column_value_list` — the same per-column typed slots
//! Form B uses. So Form C values are now type-checked and
//! count-checked at parse time, not only at bind time.
//!
//! The previous commit's Form C/A disambiguation means
//! column-shaped items (idents) inside the parens now flag as
//! "did you mean Form A?". This file pins both the happy-path
//! (literals only) and the Form-A-recovery (column-shaped
//! items).
//! An identifier (column name) as the first token, or an empty
//! paren, routes to Form A instead — `insert into T (Name)`
//! still surfaces the "did you mean Form A?" recovery.
use crate::typing_surface::*;
use rdbms_playground::input_render::InputState;
// =========================================================
// Form C happy path: type-correct values parse to Insert.
// =========================================================
#[test]
fn form_c_with_text_literals_parses() {
fn form_c_text_pk_correct_values_parses() {
// Items(Code:text, Title:text) — Form C expects two text
// values (no auto-gen columns to skip).
let schema = schema_text_pk();
let a = assess_at_end(
"insert into Items ('SKU-1', 'Widget')",
@@ -26,32 +32,128 @@ fn form_c_with_text_literals_parses() {
);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
crate::snap!("form_c_text_literals", a);
crate::snap!("form_c_text_pk_valid", a);
}
#[test]
fn form_c_with_mixed_literals_parses() {
fn form_c_serial_pk_correct_values_parses() {
// Customers(id:serial, Name:text, Email:text) — Form C
// skips the serial `id`, expects two text values.
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers (1, 'Alice', 'a@b.c')",
"insert into Customers ('Alice', 'a@b.c')",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
crate::snap!("form_c_mixed_literals", a);
crate::snap!("form_c_serial_pk_valid", a);
}
#[test]
fn form_c_with_null_first_parses() {
fn form_c_with_null_value_parses() {
// null is type-compatible with any slot.
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers (null, 'Alice', 'a@b.c')",
"insert into Customers (null, 'a@b.c')",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
crate::snap!("form_c_null_first", a);
crate::snap!("form_c_null_value", a);
}
// =========================================================
// Form C is now type-aware (the §2.2 limitation is fixed).
// =========================================================
#[test]
fn form_c_rejects_number_for_text_column() {
// `3.14` lands in the Name(text) slot — the typed slot
// rejects it at parse time. Before Form-C type-awareness
// this parsed Valid and only failed at bind time.
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers (3.14, 'a@b.c')",
&schema,
);
assert!(
!matches!(a.state, InputState::Valid),
"Form C should now type-check `3.14` against Name(text), got {:?}",
a.state,
);
crate::snap!("form_c_type_mismatch", a);
}
#[test]
fn form_c_wrong_value_count_is_invalid() {
// Customers Form C expects exactly two values (id:serial
// skipped). Three values is a count mismatch — caught at
// parse time now.
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers ('Alice', 'a@b.c', 'extra')",
&schema,
);
assert!(
!matches!(a.state, InputState::Valid),
"Form C with too many values must be invalid, got {:?}",
a.state,
);
crate::snap!("form_c_wrong_count", a);
}
// =========================================================
// Form C typed-slot prose — the per-column hint Form B has
// is now available in Form C too.
// =========================================================
#[test]
fn form_c_second_slot_shows_typed_prose_for_column() {
// First token `'Alice'` is a string literal → Form C. At
// the second slot the hint names the Email column.
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers ('Alice', ",
&schema,
);
let prose = hint_prose(&a).unwrap_or_else(|| {
panic!("expected Prose at Form C second slot, got {:?}", a.hint)
});
assert!(
prose.contains("Email"),
"Form C second slot should name `Email`, got prose: {prose:?}",
);
crate::snap!("form_c_typed_prose", a);
}
// =========================================================
// In-progress Form C classifies as IncompleteAtEof.
// =========================================================
#[test]
fn form_c_in_progress_after_comma_is_incomplete() {
let schema = schema_serial_pk();
let a = assess_at_end("insert into Customers ('Alice', ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("form_c_in_progress_after_comma", a);
}
#[test]
fn form_c_in_progress_without_close_paren_is_incomplete() {
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers ('Alice', 'a@b.c'",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("form_c_in_progress_no_close", a);
}
// =========================================================
// Form A recovery: a column-name identifier as the first
// paren token routes to Form A — `insert into T (Name)`
// without `values` flags as Form-A-in-progress.
// =========================================================
#[test]
fn form_c_with_column_shaped_item_flags_as_form_a_in_progress() {
let schema = schema_serial_pk();
@@ -73,37 +175,3 @@ fn form_c_with_two_columns_flags_as_form_a_in_progress() {
assert_candidate_present(&a, &["values"]);
crate::snap!("form_c_two_columns_recovery", a);
}
#[test]
fn form_c_type_unaware_grammar_accepts_decimal_for_int_column() {
// Form C's grammar uses INSERT_PAREN_LIST (the pre-Phase-D
// schemaless choice), so type mismatches aren't caught at
// parse time. Bind time catches them. Handoff §2.2
// documents this as known.
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers (3.14, 'Alice', 'a@b.c')",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
crate::snap!("form_c_type_unaware", a);
}
#[test]
fn form_c_in_progress_after_comma_is_incomplete() {
let schema = schema_serial_pk();
let a = assess_at_end("insert into Customers (1, ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("form_c_in_progress_after_comma", a);
}
#[test]
fn form_c_in_progress_without_close_paren_is_incomplete() {
let schema = schema_serial_pk();
let a = assess_at_end(
"insert into Customers (1, 'Alice'",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("form_c_in_progress_no_close", a);
}
@@ -8,9 +8,19 @@ Assessment {
cursor: 23,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
),
Candidates {
items: [
Candidate {
text: "Name",
kind: Identifier,
},
Candidate {
text: "id",
kind: Identifier,
},
],
selected: None,
},
),
completion: Some(
Completion {
@@ -20,18 +30,6 @@ Assessment {
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
Candidate {
text: "true",
kind: Keyword,
},
Candidate {
text: "false",
kind: Keyword,
},
Candidate {
text: "Name",
kind: Identifier,
@@ -8,9 +8,23 @@ Assessment {
cursor: 23,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
),
Candidates {
items: [
Candidate {
text: "Email",
kind: Identifier,
},
Candidate {
text: "Name",
kind: Identifier,
},
Candidate {
text: "id",
kind: Identifier,
},
],
selected: None,
},
),
completion: Some(
Completion {
@@ -20,18 +34,6 @@ Assessment {
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
Candidate {
text: "true",
kind: Keyword,
},
Candidate {
text: "false",
kind: Keyword,
},
Candidate {
text: "Email",
kind: Identifier,
@@ -8,9 +8,19 @@ Assessment {
cursor: 19,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
),
Candidates {
items: [
Candidate {
text: "Code",
kind: Identifier,
},
Candidate {
text: "Title",
kind: Identifier,
},
],
selected: None,
},
),
completion: Some(
Completion {
@@ -20,18 +30,6 @@ Assessment {
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
Candidate {
text: "true",
kind: Keyword,
},
Candidate {
text: "false",
kind: Keyword,
},
Candidate {
text: "Code",
kind: Identifier,
@@ -8,9 +8,23 @@ Assessment {
cursor: 20,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
),
Candidates {
items: [
Candidate {
text: "CustId",
kind: Identifier,
},
Candidate {
text: "OrderId",
kind: Identifier,
},
Candidate {
text: "Total",
kind: Identifier,
},
],
selected: None,
},
),
completion: Some(
Completion {
@@ -20,18 +34,6 @@ Assessment {
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
Candidate {
text: "true",
kind: Keyword,
},
Candidate {
text: "false",
kind: Keyword,
},
Candidate {
text: "CustId",
kind: Identifier,
@@ -8,9 +8,23 @@ Assessment {
cursor: 27,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
),
Candidates {
items: [
Candidate {
text: "Email",
kind: Identifier,
},
Candidate {
text: "Name",
kind: Identifier,
},
Candidate {
text: "id",
kind: Identifier,
},
],
selected: None,
},
),
completion: Some(
Completion {
@@ -20,18 +34,6 @@ Assessment {
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
Candidate {
text: "true",
kind: Keyword,
},
Candidate {
text: "false",
kind: Keyword,
},
Candidate {
text: "Email",
kind: Identifier,
@@ -1,22 +1,22 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers (1, \" cursor=26"
description: "input=\"insert into Customers ('Alice', \" cursor=32"
expression: "& a"
---
Assessment {
input: "insert into Customers (1, ",
cursor: 26,
input: "insert into Customers ('Alice', ",
cursor: 32,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
"for `Email`: Type a quoted string (e.g. 'Alice') or null",
),
),
completion: Some(
Completion {
replaced_range: (
26,
26,
32,
32,
),
partial_prefix: "",
candidates: [
@@ -24,26 +24,6 @@ Assessment {
text: "null",
kind: Keyword,
},
Candidate {
text: "true",
kind: Keyword,
},
Candidate {
text: "false",
kind: Keyword,
},
Candidate {
text: "Email",
kind: Identifier,
},
Candidate {
text: "Name",
kind: Identifier,
},
Candidate {
text: "id",
kind: Identifier,
},
],
},
),
@@ -1,11 +1,11 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers (1, 'Alice'\" cursor=33"
description: "input=\"insert into Customers ('Alice', 'a@b.c'\" cursor=39"
expression: "& a"
---
Assessment {
input: "insert into Customers (1, 'Alice'",
cursor: 33,
input: "insert into Customers ('Alice', 'a@b.c'",
cursor: 39,
state: IncompleteAtEof,
hint: Some(
Prose(
@@ -0,0 +1,35 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers (3.14, 'a@b.c')\" cursor=37"
expression: "& a"
---
Assessment {
input: "insert into Customers (3.14, 'a@b.c')",
cursor: 37,
state: DefiniteErrorAt(
23,
),
hint: Some(
Prose(
"for `Name`: Type a quoted string (e.g. 'Alice') or null (`id` auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)",
),
),
completion: Some(
Completion {
replaced_range: (
37,
37,
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
],
},
),
parse_result: Err(
"Invalid(definite)",
),
}
@@ -0,0 +1,33 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers ('Alice', \" cursor=32"
expression: "& a"
---
Assessment {
input: "insert into Customers ('Alice', ",
cursor: 32,
state: IncompleteAtEof,
hint: Some(
Prose(
"for `Email`: Type a quoted string (e.g. 'Alice') or null",
),
),
completion: Some(
Completion {
replaced_range: (
32,
32,
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -1,11 +1,11 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers (1, 'Alice', 'a@b.c')\" cursor=43"
description: "input=\"insert into Customers ('Alice', 'a@b.c')\" cursor=40"
expression: "& a"
---
Assessment {
input: "insert into Customers (1, 'Alice', 'a@b.c')",
cursor: 43,
input: "insert into Customers ('Alice', 'a@b.c')",
cursor: 40,
state: Valid,
hint: Some(
Candidates {
@@ -21,8 +21,8 @@ Assessment {
completion: Some(
Completion {
replaced_range: (
43,
43,
40,
40,
),
partial_prefix: "",
candidates: [
@@ -1,39 +0,0 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers (null, 'Alice', 'a@b.c')\" cursor=46"
expression: "& a"
---
Assessment {
input: "insert into Customers (null, 'Alice', 'a@b.c')",
cursor: 46,
state: Valid,
hint: Some(
Candidates {
items: [
Candidate {
text: "values",
kind: Keyword,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
46,
46,
),
partial_prefix: "",
candidates: [
Candidate {
text: "values",
kind: Keyword,
},
],
},
),
parse_result: Ok(
"Insert",
),
}
@@ -1,11 +1,11 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers (3.14, 'Alice', 'a@b.c')\" cursor=46"
description: "input=\"insert into Customers (null, 'a@b.c')\" cursor=37"
expression: "& a"
---
Assessment {
input: "insert into Customers (3.14, 'Alice', 'a@b.c')",
cursor: 46,
input: "insert into Customers (null, 'a@b.c')",
cursor: 37,
state: Valid,
hint: Some(
Candidates {
@@ -21,8 +21,8 @@ Assessment {
completion: Some(
Completion {
replaced_range: (
46,
46,
37,
37,
),
partial_prefix: "",
candidates: [
@@ -0,0 +1,21 @@
---
source: tests/typing_surface/insert_form_c.rs
description: "input=\"insert into Customers ('Alice', 'a@b.c', 'extra')\" cursor=49"
expression: "& a"
---
Assessment {
input: "insert into Customers ('Alice', 'a@b.c', 'extra')",
cursor: 49,
state: DefiniteErrorAt(
39,
),
hint: Some(
Prose(
"Submit with Enter",
),
),
completion: None,
parse_result: Err(
"Invalid(definite)",
),
}