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:
claude@clouddev1
2026-05-10 17:47:02 +00:00
parent 9c4857eb50
commit 6845df1475
3 changed files with 138 additions and 25 deletions
+87
View File
@@ -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",
);
}
}
}
+1
View File
@@ -11,6 +11,7 @@
pub mod action; pub mod action;
pub mod command; pub mod command;
pub mod ident_slot;
pub mod keyword; pub mod keyword;
pub mod lexer; pub mod lexer;
pub mod parser; pub mod parser;
+50 -25
View File
@@ -17,6 +17,7 @@ use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter, ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
}; };
use crate::dsl::ident_slot::IdentSlot;
use crate::dsl::keyword::{Keyword, Punct}; use crate::dsl::keyword::{Keyword, Punct};
use crate::dsl::lexer::{LexError, Token, TokenKind, lex}; use crate::dsl::lexer::{LexError, Token, TokenKind, lex};
use crate::dsl::types::Type; use crate::dsl::types::Type;
@@ -175,8 +176,12 @@ fn punct<'a>(
.as_context() .as_context()
} }
/// Match any identifier token, returning its name. /// Match any identifier token, returning its name. Internal —
fn ident<'a>() /// 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 { -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
select_ref! { select_ref! {
Token { kind: TokenKind::Identifier(s), .. } => s.clone() Token { kind: TokenKind::Identifier(s), .. } => s.clone()
@@ -185,6 +190,19 @@ fn ident<'a>()
.as_context() .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`. /// Match a number-literal token, returning a `Value::Number`.
fn number_literal<'a>() fn number_literal<'a>()
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone { -> 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 { -> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
let create_table = kw(Keyword::Create) let create_table = kw(Keyword::Create)
.ignore_then(kw(Keyword::Table)) .ignore_then(kw(Keyword::Table))
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::NewName))
.then(with_pk_clause()) .then(with_pk_clause())
.try_map(|(name, pk_specs), span| { .try_map(|(name, pk_specs), span| {
if pk_specs.is_empty() { if pk_specs.is_empty() {
@@ -280,7 +298,7 @@ fn command_parser<'a>()
let drop_table = kw(Keyword::Drop) let drop_table = kw(Keyword::Drop)
.ignore_then(kw(Keyword::Table)) .ignore_then(kw(Keyword::Table))
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.map(|name| Command::DropTable { name }); .map(|name| Command::DropTable { name });
// `add column [to] [table] <T>: <col> (<type>)`. Both // `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::Column))
.ignore_then(kw(Keyword::To).or_not()) .ignore_then(kw(Keyword::To).or_not())
.ignore_then(kw(Keyword::Table).or_not()) .ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon)) .then_ignore(punct(Punct::Colon))
.then(ident()) .then(ident_ctx(IdentSlot::NewName))
.then_ignore(punct(Punct::OpenParen)) .then_ignore(punct(Punct::OpenParen))
.then(type_keyword()) .then(type_keyword())
.then_ignore(punct(Punct::CloseParen)) .then_ignore(punct(Punct::CloseParen))
@@ -302,29 +320,29 @@ fn command_parser<'a>()
.ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::From).or_not()) .ignore_then(kw(Keyword::From).or_not())
.ignore_then(kw(Keyword::Table).or_not()) .ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon)) .then_ignore(punct(Punct::Colon))
.then(ident()) .then(ident_ctx(IdentSlot::Column))
.map(|(table, column)| Command::DropColumn { table, column }); .map(|(table, column)| Command::DropColumn { table, column });
let rename_column = kw(Keyword::Rename) let rename_column = kw(Keyword::Rename)
.ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::In).or_not()) .ignore_then(kw(Keyword::In).or_not())
.ignore_then(kw(Keyword::Table).or_not()) .ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon)) .then_ignore(punct(Punct::Colon))
.then(ident()) .then(ident_ctx(IdentSlot::Column))
.then_ignore(kw(Keyword::To)) .then_ignore(kw(Keyword::To))
.then(ident()) .then(ident_ctx(IdentSlot::NewName))
.map(|((table, old), new)| Command::RenameColumn { table, old, new }); .map(|((table, old), new)| Command::RenameColumn { table, old, new });
let change_column = kw(Keyword::Change) let change_column = kw(Keyword::Change)
.ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::In).or_not()) .ignore_then(kw(Keyword::In).or_not())
.ignore_then(kw(Keyword::Table).or_not()) .ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon)) .then_ignore(punct(Punct::Colon))
.then(ident()) .then(ident_ctx(IdentSlot::Column))
.then_ignore(punct(Punct::OpenParen)) .then_ignore(punct(Punct::OpenParen))
.then(type_keyword()) .then(type_keyword())
.then_ignore(punct(Punct::CloseParen)) .then_ignore(punct(Punct::CloseParen))
@@ -341,12 +359,12 @@ fn command_parser<'a>()
let show_data = kw(Keyword::Show) let show_data = kw(Keyword::Show)
.ignore_then(kw(Keyword::Data)) .ignore_then(kw(Keyword::Data))
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.map(|name| Command::ShowData { name }); .map(|name| Command::ShowData { name });
let show_table = kw(Keyword::Show) let show_table = kw(Keyword::Show)
.ignore_then(kw(Keyword::Table)) .ignore_then(kw(Keyword::Table))
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.map(|name| Command::ShowTable { name }); .map(|name| Command::ShowTable { name });
let insert_cmd = insert_parser(); 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 { -> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
let column_list = punct(Punct::OpenParen) let column_list = punct(Punct::OpenParen)
.ignore_then( .ignore_then(
ident() ident_ctx(IdentSlot::Column)
.separated_by(punct(Punct::Comma)) .separated_by(punct(Punct::Comma))
.at_least(1) .at_least(1)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
@@ -420,7 +438,7 @@ fn insert_parser<'a>()
kw(Keyword::Insert) kw(Keyword::Insert)
.ignore_then(kw(Keyword::Into)) .ignore_then(kw(Keyword::Into))
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then(choice(( .then(choice((
with_columns_and_values, with_columns_and_values,
with_values_keyword_only, with_values_keyword_only,
@@ -435,7 +453,7 @@ fn insert_parser<'a>()
fn update_parser<'a>() fn update_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone { -> 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_ignore(punct(Punct::Equals))
.then(value_literal()); .then(value_literal());
@@ -445,7 +463,7 @@ fn update_parser<'a>()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
kw(Keyword::Update) kw(Keyword::Update)
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(kw(Keyword::Set)) .then_ignore(kw(Keyword::Set))
.then(assignments) .then(assignments)
.then(filter_clause()) .then(filter_clause())
@@ -460,7 +478,7 @@ fn delete_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone { -> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
kw(Keyword::Delete) kw(Keyword::Delete)
.ignore_then(kw(Keyword::From)) .ignore_then(kw(Keyword::From))
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::TableName))
.then(filter_clause()) .then(filter_clause())
.map(|(table, filter)| Command::Delete { table, filter }) .map(|(table, filter)| Command::Delete { table, filter })
} }
@@ -468,7 +486,7 @@ fn delete_parser<'a>()
fn filter_clause<'a>() fn filter_clause<'a>()
-> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone { -> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone {
let where_clause = kw(Keyword::Where) let where_clause = kw(Keyword::Where)
.ignore_then(ident()) .ignore_then(ident_ctx(IdentSlot::Column))
.then_ignore(punct(Punct::Equals)) .then_ignore(punct(Punct::Equals))
.then(value_literal()) .then(value_literal())
.map(|(column, value)| RowFilter::Where { column, value }); .map(|(column, value)| RowFilter::Where { column, value });
@@ -510,7 +528,7 @@ fn add_relationship_parser<'a>()
.ignore_then(punct(Punct::Colon)) .ignore_then(punct(Punct::Colon))
.ignore_then(n_ident); .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) kw(Keyword::Add)
.ignore_then(one_to_n) .ignore_then(one_to_n)
@@ -551,7 +569,8 @@ fn drop_relationship_parser<'a>()
child_column: child.1, 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) kw(Keyword::Drop)
.ignore_then(kw(Keyword::Relationship)) .ignore_then(kw(Keyword::Relationship))
@@ -561,7 +580,9 @@ fn drop_relationship_parser<'a>()
fn qualified_column<'a>() fn qualified_column<'a>()
-> impl Parser<'a, &'a [Token], (String, String), extra::Err<Rich<'a, Token>>> + Clone { -> 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< fn referential_clauses<'a>() -> impl Parser<
@@ -659,7 +680,11 @@ fn change_column_flags<'a>()
fn with_pk_clause<'a>() fn with_pk_clause<'a>()
-> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err<Rich<'a, Token>>> + Clone { -> 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_ignore(punct(Punct::Colon))
.then(type_keyword()) .then(type_keyword())
.map(|(name, ty)| (name, ty)); .map(|(name, ty)| (name, ty));