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
+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"),
)),
})
}