DSL parser, async DB worker, types, history, metadata, polish

Track 1 implementation plus polish round.

Parser (chumsky):
- Grammar-based DSL producing a typed Command AST.
- create table X with pk [name:type[,name:type...]] supports
  arbitrary names, any user type, compound PKs natively. Bare
  form errors with a friendly hint pointing at `with pk`.
- add column to table X: Name (type); drop table X.
- Required clauses use keyword grammar; -- reserved for opt-in
  flags (ADR-0009). Custom Rich reasons preferred when surfacing
  chumsky errors so unknown-type messages list valid alternatives.

Database (ADR-0010, ADR-0012):
- rusqlite + STRICT tables + foreign_keys=ON.
- Dedicated worker thread; mpsc Request inbox, oneshot replies.
- Typed DbError with friendly_message() hook for H1.
- Internal __rdbms_playground_columns metadata table preserves
  user-facing types across schema reads, atomically maintained
  alongside DDL via Connection transactions. list_tables hides
  it via the new __rdbms_ internal-table convention.

Types (ADR-0005, ADR-0011):
- All ten user-facing types: text, int, real, decimal, bool,
  date, datetime, blob, serial, shortid.
- Type::fk_target_type() for FK-side column-type rule
  (Serial->Int, ShortId->Text, others identity) -- foundation
  for the FK iteration.

App / Runtime / UI:
- update() stays pure-sync; runtime dispatches DSL via spawned
  tasks, results post back as AppEvent::Dsl*.
- Items panel renders live tables list; output panel shows the
  user-facing structure of the current table after each DDL.
- In-memory command history (Up/Down, draft preservation,
  consecutive-duplicate dedup) -- I2 partial.
- Mouse capture removed; terminal native text selection
  restored (toggle approach revisited when scroll/click
  features land).

Docs:
- ADRs 0009 (DSL syntax conventions), 0010 (DB worker),
  0011 (FK type compat), 0012 (internal metadata table).
- requirements.md progress notes; new V4 entry for the
  scrollable session-log + inline rich rendering + Markdown
  export direction.

Tests: 103 passing (91 lib + 12 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 13:32:19 +00:00
parent 25a0f1260f
commit c1e52920eb
21 changed files with 3186 additions and 120 deletions
+485
View File
@@ -0,0 +1,485 @@
//! Grammar-based DSL parser built on chumsky.
//!
//! The parser produces a `Command` AST directly — there is no
//! intermediate token tree to translate. Composable rules
//! (identifier, type keyword, padded keyword) are defined once
//! and reused across command variants, which is the point of
//! choosing a grammar approach (see Phase 2/3 selection).
//!
//! Errors from chumsky are mapped to the local `ParseError` type
//! so callers do not depend on chumsky's API surface — that
//! keeps the parser swappable if we ever revisit the choice.
use chumsky::error::RichReason;
use chumsky::prelude::*;
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::types::Type;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("could not parse command: {message}")]
Invalid { message: String, position: usize },
#[error("empty input")]
Empty,
}
impl ParseError {
#[must_use]
pub const fn position(&self) -> Option<usize> {
match self {
Self::Invalid { position, .. } => Some(*position),
Self::Empty => None,
}
}
}
/// Parse a single DSL command.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
match command_parser().parse(trimmed).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, trimmed)),
}
}
fn into_parse_error(errs: &[Rich<'_, char>], input: &str) -> ParseError {
// Prefer custom-reason errors over chumsky's structural
// ones — those carry our friendly messages from `try_map`
// (e.g. "unknown type 'varchar' (expected one of: ...)").
let chosen = errs
.iter()
.find(|e| has_custom_reason(e.reason()))
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
let span = chosen.span();
let position = span.start;
let message = humanise(chosen, input);
ParseError::Invalid { message, position }
}
const fn has_custom_reason<T, C>(reason: &RichReason<'_, T, C>) -> bool {
matches!(reason, RichReason::Custom(_))
}
fn humanise(err: &Rich<'_, char>, input: &str) -> String {
// For custom errors, the underlying message is what we want
// to show, not chumsky's "found ... expected ..." rendering.
if let Some(msg) = first_custom_message(err.reason()) {
return msg;
}
let span = err.span();
let snippet: String = input
.chars()
.skip(span.start)
.take((span.end - span.start).max(1))
.collect();
if snippet.is_empty() {
format!("{err}")
} else {
format!("{err} (near `{snippet}`)")
}
}
fn first_custom_message<T>(reason: &RichReason<'_, T, String>) -> Option<String> {
match reason {
RichReason::Custom(msg) => Some(msg.clone()),
RichReason::ExpectedFound { .. } => None,
}
}
/// The top-level command parser.
fn command_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let create_table = keyword_ci("create")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.then(with_pk_clause())
.try_map(|(name, pk_specs), span| {
if pk_specs.is_empty() {
return Err(Rich::custom(
span,
"tables need at least one column. Add `with pk` for a default \
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
Use a comma-separated list for compound primary keys."
.to_string(),
));
}
let columns: Vec<ColumnSpec> = pk_specs
.iter()
.map(|(n, t)| ColumnSpec {
name: n.clone(),
ty: *t,
})
.collect();
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
Ok(Command::CreateTable {
name,
columns,
primary_key,
})
});
let drop_table = keyword_ci("drop")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.map(|name| Command::DropTable { name });
let add_column = keyword_ci("add")
.ignore_then(keyword_ci("column"))
.ignore_then(keyword_ci("to"))
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.then_ignore(just(':').padded())
.then(identifier())
.then_ignore(just('(').padded())
.then(type_keyword())
.then_ignore(just(')').padded())
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
choice((create_table, drop_table, add_column))
.padded()
.then_ignore(end())
}
/// Parse the optional `with pk [<spec>]` clause that may follow
/// `create table <Name>`. Returns the list of (name, type) pairs
/// that form the primary key. An absent clause returns an empty
/// vector; a present `with pk` (no spec) returns the default
/// `id:serial`. Compound PK is a comma-separated list of specs.
fn with_pk_clause<'a>()
-> impl Parser<'a, &'a str, Vec<(String, Type)>, extra::Err<Rich<'a, char>>> + Clone {
let single = identifier()
.then_ignore(just(':').padded())
.then(type_keyword())
.map(|(name, ty)| (name, ty));
let spec_list = single
.clone()
.separated_by(just(',').padded())
.at_least(1)
.collect::<Vec<_>>();
keyword_ci("with")
.ignore_then(keyword_ci("pk"))
.ignore_then(spec_list.or_not())
.map(|maybe_specs| {
// `with pk` alone defaults to a serial id PK.
maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)])
})
.or_not()
.map(Option::unwrap_or_default)
}
/// Identifier: a letter or underscore followed by letters,
/// digits, or underscores. Returned as an owned `String` so the
/// `Command` AST has no lifetime tying it to the input.
fn identifier<'a>()
-> impl Parser<'a, &'a str, String, extra::Err<Rich<'a, char>>> + Clone {
any()
.filter(|c: &char| c.is_ascii_alphabetic() || *c == '_')
.then(
any()
.filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_')
.repeated()
.collect::<Vec<_>>(),
)
.map(|(first, rest)| {
let mut s = String::with_capacity(rest.len() + 1);
s.push(first);
s.extend(rest);
s
})
.padded()
}
/// One of the supported type keywords, mapped to `Type`. The
/// `try_map` yields a `Custom` Rich error on unknown input,
/// which carries the friendly "unknown type 'X' (expected one
/// of: ...)" message — surfaced via `humanise()`. Note: no
/// `.labelled` here, because that would replace the custom
/// message with a generic "expected type".
fn type_keyword<'a>()
-> impl Parser<'a, &'a str, Type, extra::Err<Rich<'a, char>>> + Clone {
let alphabetic = any()
.filter(|c: &char| c.is_ascii_alphabetic())
.repeated()
.at_least(1)
.collect::<String>();
alphabetic.padded().try_map(|word, span| {
word.parse::<Type>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
}
/// Case-insensitive keyword matcher. Consumes leading and
/// trailing whitespace and, importantly, requires a word
/// boundary so `create` does not match a prefix of `created`.
fn keyword_ci<'a>(
kw: &'static str,
) -> impl Parser<'a, &'a str, (), extra::Err<Rich<'a, char>>> + Clone {
let alphabetic = any()
.filter(|c: &char| c.is_ascii_alphabetic())
.repeated()
.at_least(1)
.collect::<String>();
alphabetic.padded().try_map(move |word, span| {
if word.eq_ignore_ascii_case(kw) {
Ok(())
} else {
Err(Rich::custom(
span,
format!("expected '{kw}', found '{word}'"),
))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn ok(input: &str) -> Command {
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
}
fn err(input: &str) -> ParseError {
parse_command(input).expect_err("expected parse error")
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
}
}
#[test]
fn bare_create_table_errors_with_helpful_message() {
let e = err("create table Customers");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("with pk"),
"error should mention `with pk`:\n{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn create_table_with_pk_default_is_id_serial() {
assert_eq!(
ok("create table Customers with pk"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
#[test]
fn create_table_with_named_typed_pk() {
assert_eq!(
ok("create table Customers with pk email:text"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn create_table_with_compound_pk() {
assert_eq!(
ok("create table OrderLines with pk order_id:int,product_id:int"),
Command::CreateTable {
name: "OrderLines".to_string(),
columns: vec![
col("order_id", Type::Int),
col("product_id", Type::Int),
],
primary_key: vec!["order_id".to_string(), "product_id".to_string()],
}
);
}
#[test]
fn create_table_pk_accepts_any_user_type() {
// Pedagogical freedom — the grammar imposes no
// "sensible PK type" filter. Every user-facing type is
// accepted; learners discover for themselves.
for ty in Type::all() {
let input = format!("create table T with pk col:{}", ty.keyword());
let cmd = ok(&input);
if let Command::CreateTable {
columns,
primary_key,
..
} = cmd
{
assert_eq!(columns[0].ty, *ty);
assert_eq!(primary_key, vec!["col".to_string()]);
} else {
panic!("expected CreateTable for {input}");
}
}
}
#[test]
fn create_table_pk_tolerates_whitespace() {
assert_eq!(
ok("create table T with pk id : serial"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
assert_eq!(
ok("create table T with pk a : int , b : int"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("a", Type::Int), col("b", Type::Int)],
primary_key: vec!["a".to_string(), "b".to_string()],
}
);
}
#[test]
fn create_table_keywords_are_case_insensitive() {
assert_eq!(
ok("CREATE TABLE Customers WITH PK email:TEXT"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn drop_table_simple() {
assert_eq!(
ok("drop table Customers"),
Command::DropTable {
name: "Customers".to_string()
}
);
}
#[test]
fn add_column_simple() {
assert_eq!(
ok("add column to table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_with_each_supported_type() {
for ty in Type::all() {
let input = format!("add column to table T: C ({})", ty.keyword());
assert_eq!(
ok(&input),
Command::AddColumn {
table: "T".to_string(),
column: "C".to_string(),
ty: *ty,
}
);
}
}
#[test]
fn add_column_tolerates_whitespace_around_punctuation() {
assert_eq!(
ok("add column to table T:Name(text)"),
Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn empty_input_is_an_explicit_empty_error() {
assert_eq!(parse_command(""), Err(ParseError::Empty));
assert_eq!(parse_command(" "), Err(ParseError::Empty));
}
#[test]
fn unknown_command_errors() {
let e = err("frobulate Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn unknown_type_errors_with_alternatives_listed() {
let e = err("add column to table T: Name (varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("varchar"),
"error should mention the bad type: {message}"
);
assert!(
message.contains("expected one of"),
"error should list valid alternatives: {message}"
);
assert!(
message.contains("text") && message.contains("shortid"),
"error should name the alternatives: {message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn unknown_pk_type_errors_with_alternatives_listed() {
let e = err("create table T with pk id:varchar");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "{message}");
assert!(message.contains("expected one of"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn trailing_garbage_errors() {
let e = err("create table Customers with pk and pickles");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_must_start_with_letter_or_underscore() {
let e = err("create table 1Customers with pk");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(
ok("create table customer_v2 with pk"),
Command::CreateTable {
name: "customer_v2".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
}