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:
+153
-13
@@ -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"),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user