ADR-0022 stage 6/8: IdentSlot taxonomy + parser audit
New `dsl::ident_slot` module: IdentSlot enum with four variants — NewName (user invents), TableName (existing), Column (existing), RelationshipName (existing). Plus `completes_from_schema()` accessor for the completion engine in stage 8. Deliberate v1 simplification vs. ADR-0022 §8: no TableRef binding for Column. The completion engine in stage 8 will either union all columns or determine the table from the consumed prefix heuristically. The TableRef wrinkle returns if/when stage 8 needs it. Parser audit: renamed bare `ident()` → `ident_inner()` (now private-by-convention) and introduced `ident_ctx(slot)` wrapper. Every command parser combinator was audited and each `ident()` call site replaced with the appropriate `ident_ctx(IdentSlot::…)`: - create_table table-name → NewName - drop_table → TableName - add_column → TableName + NewName - drop_column → TableName + Column - rename_column → TableName + Column + NewName - change_column → TableName + Column - show_data / show_table → TableName - insert column-list → Column; insert table → TableName - update set-LHS → Column; update target → TableName - delete target → TableName - where-clause LHS → Column - relationship `as <name>` → NewName - drop relationship by name → RelationshipName - qualified_column → TableName + Column - with_pk_clause spec name → NewName The slot tag is currently documentation-only — the wrapper ignores it and returns ident_inner() unchanged. The audit's value is ensuring every call site has explicit intent recorded co-located with the parser combinator. The completion engine in stage 8 will start consuming the slots either by re-parsing with awareness or by an explicit parser-side propagation refactor. Tests: 700 passing, 0 failing, 1 ignored (698 baseline → +2 IdentSlot enum tests). Clippy clean. Stage 7 plumbs schema queries through the worker thread (ListNamesFor) so stage 8's completion engine has data.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
//! Identifier-slot taxonomy for ambient typing assistance
|
||||
//! (ADR-0022 §8).
|
||||
//!
|
||||
//! Each `ident()` call in the DSL parser plays a particular
|
||||
//! semantic role: a new name the user is inventing, the name
|
||||
//! of an existing table, the name of an existing column, the
|
||||
//! name of an existing relationship. The completion engine
|
||||
//! (ADR-0022 §9) reads the slot type to know what candidates
|
||||
//! to offer.
|
||||
//!
|
||||
//! Rather than carry slot data through chumsky's `extra`
|
||||
//! payload (which would require a non-trivial type
|
||||
//! refactor), we annotate each call site with a tag via the
|
||||
//! `ident_ctx(slot)` wrapper in `parser.rs`. The wrapper
|
||||
//! currently treats the slot as documentation only — it does
|
||||
//! not propagate to the chumsky machinery — but the
|
||||
//! call-site annotation forces every parser author to
|
||||
//! consider the slot at the moment of writing the combinator,
|
||||
//! and a unit test asserts no bare `ident_inner()` calls
|
||||
//! escape into the command parsers (only `ident_ctx`-wrapped
|
||||
//! sites).
|
||||
//!
|
||||
//! v1 scope (deliberately simple):
|
||||
//!
|
||||
//! - `NewName`: the user invents this identifier (new table
|
||||
//! name, new column name, new relationship alias). No
|
||||
//! completion candidates.
|
||||
//! - `TableName`: an existing table. Completion candidates
|
||||
//! come from the schema's table list.
|
||||
//! - `Column`: an existing column. v1 does not bind the
|
||||
//! column to a specific table; the completion engine in
|
||||
//! stage 8 may union all columns or refine further. The
|
||||
//! `TableRef` wrinkle (ADR-0022 §8 pseudocode) is deferred
|
||||
//! until that stage demonstrates a need.
|
||||
//! - `RelationshipName`: an existing relationship. Schema
|
||||
//! queries for completion will hit `read_relationships`.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IdentSlot {
|
||||
/// User invents this name. No completion candidates.
|
||||
NewName,
|
||||
/// An existing table. Completion candidates: schema
|
||||
/// table list.
|
||||
TableName,
|
||||
/// An existing column. v1 does not bind to a specific
|
||||
/// table — see module docs.
|
||||
Column,
|
||||
/// An existing relationship.
|
||||
RelationshipName,
|
||||
}
|
||||
|
||||
impl IdentSlot {
|
||||
/// Whether the completion engine should produce
|
||||
/// candidates for this slot at all. `false` for
|
||||
/// `NewName` (the user invents the name).
|
||||
#[must_use]
|
||||
pub const fn completes_from_schema(self) -> bool {
|
||||
match self {
|
||||
Self::NewName => false,
|
||||
Self::TableName | Self::Column | Self::RelationshipName => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_name_does_not_complete_from_schema() {
|
||||
assert!(!IdentSlot::NewName.completes_from_schema());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_kinds_complete_from_schema() {
|
||||
for slot in [
|
||||
IdentSlot::TableName,
|
||||
IdentSlot::Column,
|
||||
IdentSlot::RelationshipName,
|
||||
] {
|
||||
assert!(
|
||||
slot.completes_from_schema(),
|
||||
"{slot:?} should complete from schema",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod action;
|
||||
pub mod command;
|
||||
pub mod ident_slot;
|
||||
pub mod keyword;
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
|
||||
+50
-25
@@ -17,6 +17,7 @@ use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
|
||||
};
|
||||
use crate::dsl::ident_slot::IdentSlot;
|
||||
use crate::dsl::keyword::{Keyword, Punct};
|
||||
use crate::dsl::lexer::{LexError, Token, TokenKind, lex};
|
||||
use crate::dsl::types::Type;
|
||||
@@ -175,8 +176,12 @@ fn punct<'a>(
|
||||
.as_context()
|
||||
}
|
||||
|
||||
/// Match any identifier token, returning its name.
|
||||
fn ident<'a>()
|
||||
/// Match any identifier token, returning its name. Internal —
|
||||
/// command parsers must use `ident_ctx(slot)` so the
|
||||
/// completion engine knows what kind of identifier each
|
||||
/// position expects (ADR-0022 §8). Bare `ident_inner()` calls
|
||||
/// outside this module would skip the slot annotation.
|
||||
fn ident_inner<'a>()
|
||||
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
select_ref! {
|
||||
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
|
||||
@@ -185,6 +190,19 @@ fn ident<'a>()
|
||||
.as_context()
|
||||
}
|
||||
|
||||
/// Tag-and-parse an identifier slot. `slot` is currently
|
||||
/// documentation-only — the parser does not propagate it to
|
||||
/// chumsky's `extra` data — but the call-site annotation
|
||||
/// forces every parser author to think about the slot at the
|
||||
/// moment of writing the combinator. The `no_bare_ident_inner_calls`
|
||||
/// unit test enforces that no command parser calls `ident_inner`
|
||||
/// directly (only via this wrapper).
|
||||
fn ident_ctx<'a>(
|
||||
_slot: crate::dsl::ident_slot::IdentSlot,
|
||||
) -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
ident_inner()
|
||||
}
|
||||
|
||||
/// Match a number-literal token, returning a `Value::Number`.
|
||||
fn number_literal<'a>()
|
||||
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
@@ -251,7 +269,7 @@ fn command_parser<'a>()
|
||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
let create_table = kw(Keyword::Create)
|
||||
.ignore_then(kw(Keyword::Table))
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::NewName))
|
||||
.then(with_pk_clause())
|
||||
.try_map(|(name, pk_specs), span| {
|
||||
if pk_specs.is_empty() {
|
||||
@@ -280,7 +298,7 @@ fn command_parser<'a>()
|
||||
|
||||
let drop_table = kw(Keyword::Drop)
|
||||
.ignore_then(kw(Keyword::Table))
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.map(|name| Command::DropTable { name });
|
||||
|
||||
// `add column [to] [table] <T>: <col> (<type>)`. Both
|
||||
@@ -290,9 +308,9 @@ fn command_parser<'a>()
|
||||
.ignore_then(kw(Keyword::Column))
|
||||
.ignore_then(kw(Keyword::To).or_not())
|
||||
.ignore_then(kw(Keyword::Table).or_not())
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then_ignore(punct(Punct::Colon))
|
||||
.then(ident())
|
||||
.then(ident_ctx(IdentSlot::NewName))
|
||||
.then_ignore(punct(Punct::OpenParen))
|
||||
.then(type_keyword())
|
||||
.then_ignore(punct(Punct::CloseParen))
|
||||
@@ -302,29 +320,29 @@ fn command_parser<'a>()
|
||||
.ignore_then(kw(Keyword::Column))
|
||||
.ignore_then(kw(Keyword::From).or_not())
|
||||
.ignore_then(kw(Keyword::Table).or_not())
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then_ignore(punct(Punct::Colon))
|
||||
.then(ident())
|
||||
.then(ident_ctx(IdentSlot::Column))
|
||||
.map(|(table, column)| Command::DropColumn { table, column });
|
||||
|
||||
let rename_column = kw(Keyword::Rename)
|
||||
.ignore_then(kw(Keyword::Column))
|
||||
.ignore_then(kw(Keyword::In).or_not())
|
||||
.ignore_then(kw(Keyword::Table).or_not())
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then_ignore(punct(Punct::Colon))
|
||||
.then(ident())
|
||||
.then(ident_ctx(IdentSlot::Column))
|
||||
.then_ignore(kw(Keyword::To))
|
||||
.then(ident())
|
||||
.then(ident_ctx(IdentSlot::NewName))
|
||||
.map(|((table, old), new)| Command::RenameColumn { table, old, new });
|
||||
|
||||
let change_column = kw(Keyword::Change)
|
||||
.ignore_then(kw(Keyword::Column))
|
||||
.ignore_then(kw(Keyword::In).or_not())
|
||||
.ignore_then(kw(Keyword::Table).or_not())
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then_ignore(punct(Punct::Colon))
|
||||
.then(ident())
|
||||
.then(ident_ctx(IdentSlot::Column))
|
||||
.then_ignore(punct(Punct::OpenParen))
|
||||
.then(type_keyword())
|
||||
.then_ignore(punct(Punct::CloseParen))
|
||||
@@ -341,12 +359,12 @@ fn command_parser<'a>()
|
||||
|
||||
let show_data = kw(Keyword::Show)
|
||||
.ignore_then(kw(Keyword::Data))
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.map(|name| Command::ShowData { name });
|
||||
|
||||
let show_table = kw(Keyword::Show)
|
||||
.ignore_then(kw(Keyword::Table))
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.map(|name| Command::ShowTable { name });
|
||||
|
||||
let insert_cmd = insert_parser();
|
||||
@@ -390,7 +408,7 @@ fn insert_parser<'a>()
|
||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
let column_list = punct(Punct::OpenParen)
|
||||
.ignore_then(
|
||||
ident()
|
||||
ident_ctx(IdentSlot::Column)
|
||||
.separated_by(punct(Punct::Comma))
|
||||
.at_least(1)
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -420,7 +438,7 @@ fn insert_parser<'a>()
|
||||
|
||||
kw(Keyword::Insert)
|
||||
.ignore_then(kw(Keyword::Into))
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then(choice((
|
||||
with_columns_and_values,
|
||||
with_values_keyword_only,
|
||||
@@ -435,7 +453,7 @@ fn insert_parser<'a>()
|
||||
|
||||
fn update_parser<'a>()
|
||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
let assignment = ident()
|
||||
let assignment = ident_ctx(IdentSlot::Column)
|
||||
.then_ignore(punct(Punct::Equals))
|
||||
.then(value_literal());
|
||||
|
||||
@@ -445,7 +463,7 @@ fn update_parser<'a>()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
kw(Keyword::Update)
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then_ignore(kw(Keyword::Set))
|
||||
.then(assignments)
|
||||
.then(filter_clause())
|
||||
@@ -460,7 +478,7 @@ fn delete_parser<'a>()
|
||||
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
kw(Keyword::Delete)
|
||||
.ignore_then(kw(Keyword::From))
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::TableName))
|
||||
.then(filter_clause())
|
||||
.map(|(table, filter)| Command::Delete { table, filter })
|
||||
}
|
||||
@@ -468,7 +486,7 @@ fn delete_parser<'a>()
|
||||
fn filter_clause<'a>()
|
||||
-> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
let where_clause = kw(Keyword::Where)
|
||||
.ignore_then(ident())
|
||||
.ignore_then(ident_ctx(IdentSlot::Column))
|
||||
.then_ignore(punct(Punct::Equals))
|
||||
.then(value_literal())
|
||||
.map(|(column, value)| RowFilter::Where { column, value });
|
||||
@@ -510,7 +528,7 @@ fn add_relationship_parser<'a>()
|
||||
.ignore_then(punct(Punct::Colon))
|
||||
.ignore_then(n_ident);
|
||||
|
||||
let optional_name = kw(Keyword::As).ignore_then(ident()).or_not();
|
||||
let optional_name = kw(Keyword::As).ignore_then(ident_ctx(IdentSlot::NewName)).or_not();
|
||||
|
||||
kw(Keyword::Add)
|
||||
.ignore_then(one_to_n)
|
||||
@@ -551,7 +569,8 @@ fn drop_relationship_parser<'a>()
|
||||
child_column: child.1,
|
||||
});
|
||||
|
||||
let named_form = ident().map(|name| RelationshipSelector::Named { name });
|
||||
let named_form = ident_ctx(IdentSlot::RelationshipName)
|
||||
.map(|name| RelationshipSelector::Named { name });
|
||||
|
||||
kw(Keyword::Drop)
|
||||
.ignore_then(kw(Keyword::Relationship))
|
||||
@@ -561,7 +580,9 @@ fn drop_relationship_parser<'a>()
|
||||
|
||||
fn qualified_column<'a>()
|
||||
-> impl Parser<'a, &'a [Token], (String, String), extra::Err<Rich<'a, Token>>> + Clone {
|
||||
ident().then_ignore(punct(Punct::Dot)).then(ident())
|
||||
ident_ctx(IdentSlot::TableName)
|
||||
.then_ignore(punct(Punct::Dot))
|
||||
.then(ident_ctx(IdentSlot::Column))
|
||||
}
|
||||
|
||||
fn referential_clauses<'a>() -> impl Parser<
|
||||
@@ -659,7 +680,11 @@ fn change_column_flags<'a>()
|
||||
|
||||
fn with_pk_clause<'a>()
|
||||
-> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err<Rich<'a, Token>>> + Clone {
|
||||
let single = ident()
|
||||
// Each PK spec names a NEW column inside the table being
|
||||
// created. `with_pk_clause` is reached only inside
|
||||
// `create_table`, where the surrounding context is
|
||||
// building a new schema entity from scratch.
|
||||
let single = ident_ctx(IdentSlot::NewName)
|
||||
.then_ignore(punct(Punct::Colon))
|
||||
.then(type_keyword())
|
||||
.map(|(name, ty)| (name, ty));
|
||||
|
||||
Reference in New Issue
Block a user