round-5 follow-up: completion + i18n sweep

Four user-reported gaps from the round-4 testing pass:

1. Empty-prompt hint reworded from "(no active hint)" to
   "Type a command — press Tab for options, `help` for a
   list" (6 snapshots updated to reflect 80-col truncation).

2. App-lifecycle commands (quit/q, help, rebuild, save/save as,
   new, load, export, import, mode, messages) now flow through
   the DSL parser:
   - 15 new keywords + catalog token entries
   - new Command::App(AppCommand) AST with 11 variants
   - parse-first dispatch in submit() (app commands work in
     both simple and advanced modes)
   - pre-chumsky source-slice for `export <path>` /
     `import <zip> [as <target>]` mirrors the replay precedent
   - UsageEntry registry entries so parse errors surface
     relevant usage templates
   - `mode <bad>` / `messages <bad>` use try_map for the
     friendly "unknown mode/messages" wording

3. DSL completion gaps:
   - `1:n` surfaces as a composite candidate at `add `
   - --all-rows / --create-fk / --force-conversion /
     --dont-convert surface as new CandidateKind::Flag
     candidates (coloured with tok_flag in hint panel)
   - filter_clause .labelled() wrap removed so chumsky's
     expected-set surfaces the constituent options

4. Hardcoded user-facing strings migrated to catalog:
   - 4 parser custom errors (incl. the known "tables need at
     least one column" wart)
   - UnknownType Display now via parse.custom.unknown_type
   - UI panel titles + mode labels (Output / Hint / SIMPLE /
     ADVANCED / Advanced:)
   - app.rs cascade rendering (action labels + summary)
   - runtime --resume CLI stderr
   - db.rs change-column diagnostic tables (7 headers + 3
     wrapper summaries + force-conversion hint)

Tests: 765 → 769 passing, 0 failed, 1 ignored (same doctest
as before). Clippy clean with -D warnings.

Deferred:
- ~25 thiserror #[error] attributes still hand-rolled
  (DbError, ArgsError, ArchiveError, PersistenceError,
  LockError). Tracked separately.
- DSL/SQL relationship in advanced mode — clarified
  implicitly via parse-first dispatch; broader ADR
  amendment to follow.
- Post-complete-parse completion gap (e.g. `save ` Tab
  can't offer `as` because `save` parses bare; same shape
  as `--create-fk` after a complete `add relationship`).
This commit is contained in:
claude@clouddev1
2026-05-13 15:58:29 +00:00
parent 1eb2e0d01f
commit 1e06490572
22 changed files with 1077 additions and 189 deletions
+72
View File
@@ -142,6 +142,60 @@ pub enum Command {
Replay {
path: String,
},
/// App-lifecycle command (per ADR-0003). These work in both
/// simple and advanced modes; the dispatcher branches on the
/// `Command::App(...)` variant before mode-specific routing.
/// Folded into the DSL parser so they participate in Tab
/// completion + parse-error usage templates alongside the
/// data commands.
App(AppCommand),
}
/// App-level commands surfaced through the DSL parser. These do
/// not touch the database schema or data — they affect app
/// lifecycle, mode, persistence, and verbosity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppCommand {
/// Exit cleanly. Accepts the `q` alias.
Quit,
/// Show in-app help. Body comes from `help.in_app_body`.
Help,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
/// Save the current project under a name (modal-driven).
Save,
/// Save the current project as a copy under a new path
/// (modal-driven).
SaveAs,
/// Close current, create a fresh temp project.
New,
/// Open the project picker modal.
Load,
/// Write a zip of project.yaml + data/. `path` is the user-
/// typed target (may be a name under the data root or an
/// absolute path). `None` opens the path prompt modal.
Export { path: Option<String> },
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
Messages { value: Option<MessagesValue> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeValue {
Simple,
Advanced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessagesValue {
Short,
Verbose,
}
/// Conversion mode for `change column …` (ADR-0017 §5).
@@ -218,6 +272,19 @@ impl Command {
Self::Delete { .. } => "delete from",
Self::ShowData { .. } => "show data",
Self::Replay { .. } => "replay",
Self::App(app) => match app {
AppCommand::Quit => "quit",
AppCommand::Help => "help",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
AppCommand::New => "new",
AppCommand::Load => "load",
AppCommand::Export { .. } => "export",
AppCommand::Import { .. } => "import",
AppCommand::Mode { .. } => "mode",
AppCommand::Messages { .. } => "messages",
},
}
}
@@ -254,6 +321,11 @@ impl Command {
// Replay isn't tied to a single table; the path is
// the most identifying thing for log output.
Self::Replay { path } => path,
// App commands aren't tied to schema entities — the
// verb is the most identifying thing. The
// display_subject override below provides a richer
// form when one exists.
Self::App(_) => "",
}
}
+25
View File
@@ -105,6 +105,31 @@ define_keywords! {
Restrict => "restrict",
Action => "action",
No => "no",
// App-lifecycle commands (folded into the DSL parser so they
// surface in Tab completion and the parse-error usage
// templates). The dispatch handlers in app.rs branch on the
// parsed `Command::App(...)` variant before mode-specific
// routing so these work in both simple and advanced modes
// (per ADR-0003).
Quit => "quit",
Q => "q",
Help => "help",
Rebuild => "rebuild",
Save => "save",
New => "new",
Load => "load",
Export => "export",
Import => "import",
Mode => "mode",
Messages => "messages",
// Value vocabulary for `mode <value>` and `messages <value>`.
// Free as identifier-shapes outside their slots (no command
// uses `simple` / `advanced` / `short` / `verbose` as an
// entity name today).
Simple => "simple",
Advanced => "advanced",
Short => "short",
Verbose => "verbose",
}
macro_rules! define_punct {
+2 -1
View File
@@ -22,7 +22,8 @@ pub mod value;
pub use action::ReferentialAction;
pub use command::{
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
RelationshipSelector, RowFilter,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+153 -13
View File
@@ -15,7 +15,8 @@ use chumsky::prelude::*;
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
RelationshipSelector, RowFilter,
};
use crate::dsl::ident_slot::IdentSlot;
use crate::dsl::keyword::{Keyword, Punct};
@@ -101,6 +102,9 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseErro
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
return result;
}
if let Some(result) = try_parse_app_path_command(tokens, source) {
return result;
}
match command_parser().parse(tokens).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
@@ -139,7 +143,7 @@ fn try_parse_replay_with_bare_path(
// of error chumsky would (positioned where the path
// should have started).
return Some(Err(ParseError::Invalid {
message: "expected a path after `replay`".to_string(),
message: crate::t!("parse.custom.replay_path_expected"),
position: after_replay,
at_eof: true,
expected: vec!["path".to_string()],
@@ -150,6 +154,65 @@ fn try_parse_replay_with_bare_path(
}))
}
/// `export <path>` / `import <path> [as <target>]` source-slice
/// special case. Same rationale as `try_parse_replay_with_bare_path`
/// — bare paths contain `/`, `.`, `~` which the lexer would either
/// split into separate tokens or refuse outright.
///
/// Returns `None` for the bare-keyword forms (`export`, `import`
/// alone), letting the regular chumsky path handle them and
/// surface the no-arg `Command::App(...)` variant.
fn try_parse_app_path_command(
tokens: &[Token],
source: &str,
) -> Option<Result<Command, ParseError>> {
use crate::dsl::command::AppCommand;
let first = tokens.first()?;
let kw = match &first.kind {
TokenKind::Keyword(Keyword::Export) => Keyword::Export,
TokenKind::Keyword(Keyword::Import) => Keyword::Import,
_ => return None,
};
let after = first.span.1;
let rest = source[after..].trim();
if rest.is_empty() {
return None;
}
match kw {
Keyword::Export => Some(Ok(Command::App(AppCommand::Export {
path: Some(rest.to_string()),
}))),
Keyword::Import => {
// Trailing `as` with no target is a recognised user
// mistake — surface the usage hint as a parse error
// (catalog wording stays in sync with the existing
// dispatch-time error).
if rest == "as" || rest.ends_with(" as") {
return Some(Err(ParseError::Invalid {
message: crate::t!("project.import_empty_target"),
position: after + rest.len(),
at_eof: true,
expected: Vec::new(),
}));
}
let (path, target) = match rest.split_once(" as ") {
Some((p, t)) => (p.trim().to_string(), Some(t.trim().to_string())),
None => (rest.to_string(), None),
};
if path.is_empty() {
return Some(Err(ParseError::Invalid {
message: crate::t!("project.import_usage"),
position: after,
at_eof: true,
expected: vec!["path".to_string()],
}));
}
Some(Ok(Command::App(AppCommand::Import { path, target })))
}
_ => None,
}
}
// =========================================================
// Token-aware combinator helpers (ADR-0020 §5)
// =========================================================
@@ -287,10 +350,7 @@ fn command_parser<'a>()
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(),
crate::t!("parse.custom.create_table_needs_pk"),
));
}
let columns: Vec<ColumnSpec> = pk_specs
@@ -390,6 +450,66 @@ fn command_parser<'a>()
.ignore_then(string_payload())
.map(|path| Command::Replay { path });
// ---- App-lifecycle commands -----------------------------
// No-arg variants and the keyword-value variants. Path-
// bearing variants (`export <path>`, `import <zip> [as
// <target>]`) are handled by `try_parse_app_path_command`
// BEFORE chumsky runs; the bare-keyword forms below
// surface the `Path: None` / no-source variants for
// empty-prompt completion + usage rendering.
let quit_cmd = choice((kw(Keyword::Quit), kw(Keyword::Q)))
.map(|()| Command::App(AppCommand::Quit));
let help_cmd = kw(Keyword::Help).map(|()| Command::App(AppCommand::Help));
let rebuild_cmd =
kw(Keyword::Rebuild).map(|()| Command::App(AppCommand::Rebuild));
// `save as` must be tried before bare `save` (more specific).
let save_as_cmd = kw(Keyword::Save)
.then_ignore(kw(Keyword::As))
.map(|()| Command::App(AppCommand::SaveAs));
let save_cmd = kw(Keyword::Save).map(|()| Command::App(AppCommand::Save));
let new_cmd = kw(Keyword::New).map(|()| Command::App(AppCommand::New));
let load_cmd = kw(Keyword::Load).map(|()| Command::App(AppCommand::Load));
let export_no_arg =
kw(Keyword::Export).map(|()| Command::App(AppCommand::Export { path: None }));
let import_no_arg = kw(Keyword::Import).map(|()| {
Command::App(AppCommand::Import {
path: String::new(),
target: None,
})
});
// `mode <value>` and `messages [<value>]` accept either the
// known keyword forms or any identifier — the identifier
// branch funnels through `try_map` into a friendly
// `mode.unknown` / `messages.unknown` error rather than the
// generic structural-error wording. Mirrors the type-name
// pattern in `type_keyword` (ADR-0020 §4).
let known_mode = choice((
kw(Keyword::Simple).to(ModeValue::Simple),
kw(Keyword::Advanced).to(ModeValue::Advanced),
));
let unknown_mode = ident_inner().try_map(|s, span| {
Err::<ModeValue, _>(Rich::custom(
span,
crate::t!("mode.unknown", value = s),
))
});
let mode_cmd = kw(Keyword::Mode)
.ignore_then(choice((known_mode, unknown_mode)))
.map(|value| Command::App(AppCommand::Mode { value }));
let known_messages = choice((
kw(Keyword::Short).to(MessagesValue::Short),
kw(Keyword::Verbose).to(MessagesValue::Verbose),
));
let unknown_messages = ident_inner().try_map(|s, span| {
Err::<MessagesValue, _>(Rich::custom(
span,
crate::t!("messages.unknown", value = s),
))
});
let messages_cmd = kw(Keyword::Messages)
.ignore_then(choice((known_messages, unknown_messages)).or_not())
.map(|value| Command::App(AppCommand::Messages { value }));
choice((
create_table,
// `drop column` and `drop relationship` come before
@@ -408,6 +528,19 @@ fn command_parser<'a>()
update_cmd,
delete_cmd,
replay,
// App commands. `save as` before bare `save`; everything
// else order-agnostic.
quit_cmd,
help_cmd,
rebuild_cmd,
save_as_cmd,
save_cmd,
new_cmd,
load_cmd,
export_no_arg,
import_no_arg,
mode_cmd,
messages_cmd,
))
.then_ignore(end())
}
@@ -505,9 +638,15 @@ fn filter_clause<'a>()
let all_rows = flag("all-rows").to(RowFilter::AllRows);
where_clause
.or(all_rows)
.labelled("where clause or --all-rows")
// No `.labelled()` wrap here: chumsky's expected-set then
// surfaces the constituent options (`` `where` ``,
// `` `--all-rows` ``) individually instead of collapsing
// them to a single descriptive label. The completion
// engine needs the constituents to offer Tab candidates
// (ADR-0022 §8); the resulting error prose ("expected `,`,
// `where`, or `--all-rows`") reads cleanly enough without
// hand-wrapping.
where_clause.or(all_rows)
}
fn value_literal<'a>()
@@ -625,7 +764,10 @@ fn referential_clauses<'a>() -> impl Parser<
if slot.is_some() {
return Err(Rich::custom(
span,
format!("`on {target}` specified twice"),
crate::t!(
"parse.custom.on_action_specified_twice",
target = target,
),
));
}
*slot = Some(action);
@@ -683,9 +825,7 @@ fn change_column_flags<'a>()
[single] => Ok(*single),
_ => Err(Rich::custom(
span,
"`--force-conversion` and `--dont-convert` are mutually \
exclusive — pick one."
.to_string(),
crate::t!("parse.custom.change_column_flags_exclusive"),
)),
})
}
+18 -4
View File
@@ -130,15 +130,29 @@ impl fmt::Display for Type {
}
}
/// Error returned when parsing a type keyword that isn't
/// recognised.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown type '{found}' (expected one of: {expected})")]
/// Error returned when parsing a type keyword that isn't recognised.
///
/// Display formatting flows through the i18n catalog
/// (`parse.custom.unknown_type`); call sites that do
/// `err.to_string()` get the localised wording for free.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownType {
pub found: String,
pub expected: String,
}
impl fmt::Display for UnknownType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&crate::t!(
"parse.custom.unknown_type",
found = self.found,
expected = self.expected,
))
}
}
impl std::error::Error for UnknownType {}
impl FromStr for Type {
type Err = UnknownType;
+72 -8
View File
@@ -92,6 +92,53 @@ pub const REGISTRY: &[UsageEntry] = &[
entry: Keyword::Replay,
catalog_key: "parse.usage.replay",
},
// App-lifecycle commands. Registered alongside DSL commands
// so parse-error rendering surfaces a relevant usage block
// when (e.g.) the user types `mode foo` or `import` alone.
UsageEntry {
entry: Keyword::Quit,
catalog_key: "parse.usage.quit",
},
UsageEntry {
entry: Keyword::Q,
catalog_key: "parse.usage.quit",
},
UsageEntry {
entry: Keyword::Help,
catalog_key: "parse.usage.help",
},
UsageEntry {
entry: Keyword::Rebuild,
catalog_key: "parse.usage.rebuild",
},
UsageEntry {
entry: Keyword::Save,
catalog_key: "parse.usage.save",
},
UsageEntry {
entry: Keyword::New,
catalog_key: "parse.usage.new",
},
UsageEntry {
entry: Keyword::Load,
catalog_key: "parse.usage.load",
},
UsageEntry {
entry: Keyword::Export,
catalog_key: "parse.usage.export",
},
UsageEntry {
entry: Keyword::Import,
catalog_key: "parse.usage.import",
},
UsageEntry {
entry: Keyword::Mode,
catalog_key: "parse.usage.mode",
},
UsageEntry {
entry: Keyword::Messages,
catalog_key: "parse.usage.messages",
},
];
/// Find the entry-keyword whose grammar to illustrate.
@@ -155,11 +202,12 @@ mod tests {
#[test]
fn every_command_has_a_registry_entry() {
// The parser recognises ten command-entry keywords
// (ADR-0009 + ADR-0006 + ADR-0014). Each MUST be
// represented in the registry — otherwise a parse error
// for that command renders no usage block and the H1a
// pedagogy gap reopens for that family.
// Every command-entry keyword recognised by the parser
// MUST be represented in the registry — otherwise a
// parse error for that command renders no usage block
// and the H1a pedagogy gap reopens for that family.
// Round 5 added the app-lifecycle entry keywords
// alongside the original ten DSL entry keywords.
for entry in [
Keyword::Create,
Keyword::Drop,
@@ -171,6 +219,17 @@ mod tests {
Keyword::Update,
Keyword::Delete,
Keyword::Replay,
Keyword::Quit,
Keyword::Q,
Keyword::Help,
Keyword::Rebuild,
Keyword::Save,
Keyword::New,
Keyword::Load,
Keyword::Export,
Keyword::Import,
Keyword::Mode,
Keyword::Messages,
] {
assert!(
REGISTRY.iter().any(|e| e.entry == entry),
@@ -246,14 +305,19 @@ mod tests {
}
#[test]
fn entry_keywords_alphabetised_returns_ten_unique_sorted_commands() {
fn entry_keywords_alphabetised_returns_unique_sorted_commands() {
let keys = entry_keywords_alphabetised();
let names: Vec<&str> = keys.iter().map(|k| k.as_str()).collect();
// Ten DSL entries plus the eleven app-lifecycle entries
// registered in REGISTRY (quit/q are two keywords with
// the same usage template; both surface here).
assert_eq!(
names,
vec![
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
"add", "change", "create", "delete", "drop", "export",
"help", "import", "insert", "load", "messages", "mode",
"new", "q", "quit", "rebuild", "rename", "replay",
"save", "show", "update",
],
);
}