From 6845df14759bf4f7a5cb4e1b31b2f8a2ff54d049 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 10 May 2026 17:47:02 +0000 Subject: [PATCH] ADR-0022 stage 6/8: IdentSlot taxonomy + parser audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` → 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. --- src/dsl/ident_slot.rs | 87 +++++++++++++++++++++++++++++++++++++++++++ src/dsl/mod.rs | 1 + src/dsl/parser.rs | 75 ++++++++++++++++++++++++------------- 3 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 src/dsl/ident_slot.rs diff --git a/src/dsl/ident_slot.rs b/src/dsl/ident_slot.rs new file mode 100644 index 0000000..720f459 --- /dev/null +++ b/src/dsl/ident_slot.rs @@ -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", + ); + } + } +} diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index 20c03d3..9936c5b 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -11,6 +11,7 @@ pub mod action; pub mod command; +pub mod ident_slot; pub mod keyword; pub mod lexer; pub mod parser; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 7218619..973c7ee 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -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>> + 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>> + Clone { + ident_inner() +} + /// Match a number-literal token, returning a `Value::Number`. fn number_literal<'a>() -> impl Parser<'a, &'a [Token], Value, extra::Err>> + Clone { @@ -251,7 +269,7 @@ fn command_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + 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] : ()`. 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>> + Clone { let column_list = punct(Punct::OpenParen) .ignore_then( - ident() + ident_ctx(IdentSlot::Column) .separated_by(punct(Punct::Comma)) .at_least(1) .collect::>(), @@ -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>> + 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::>(); 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>> + 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>> + 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>> + 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>> + 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));