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
+185 -9
View File
@@ -26,6 +26,20 @@ use crate::dsl::{ParseError, parse_command};
/// completion engine and the parser agree on the magic string.
const TYPE_SLOT_LABEL: &str = "type";
/// Composite literal candidates whose lexed shape is more than
/// one token but which the user types as a single fluent piece.
/// Pairs of (parser-expected-opener, full-composite-text).
///
/// The opener is the first token's backticked label as it
/// appears in `ParseError::Invalid::expected` — when present,
/// the engine surfaces the full composite text as a Tab
/// candidate.
///
/// Currently the only entry is `1:n` (start of
/// `add 1:n relationship`). New entries register here; no
/// parser change required.
const COMPOSITE_CANDIDATES: &[(&str, &str)] = &[("`1`", "1:n")];
/// Per-project schema lookup cache (ADR-0022 §9).
///
/// Held by `App::schema_cache` and consulted by the completion
@@ -68,6 +82,9 @@ pub enum CandidateKind {
Keyword,
/// A schema entity (table, column, relationship).
Identifier,
/// A `--name`-style flag. Coloured with `tok_flag` so the
/// hint matches the way it'll render in the input pane.
Flag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -164,6 +181,51 @@ pub fn candidates_at_cursor(
Vec::new()
};
// Source 1.55: flag candidates (`--name`). Like type
// names, flags live outside the Keyword enum — the parser
// labels them as backticked literals like `` `--all-rows` ``.
// Surface them as a distinct CandidateKind so the hint
// panel can colour them with `tok_flag` (matching how
// they'll appear in the input pane after insertion).
//
// The user can either Tab from a bare cursor position
// (partial empty) or after typing `--` (partial = "--").
// The standard prefix matcher walks back over alphanumeric +
// underscore, which does NOT cross `-`, so when the user
// types `--all` the partial is `all` — match the flag's
// body against that. Otherwise match the full `--name`
// against the partial (which may be empty or start with `--`).
let flags: Vec<String> = expected
.iter()
.filter_map(|item| strip_backticks(item))
.filter(|name| name.starts_with("--"))
.filter(|name| {
if partial_prefix.starts_with("--") || partial_prefix.is_empty() {
matches_prefix(name)
} else {
// partial is the alphanumeric tail past `--`
let body = &name[2..];
body.to_lowercase().starts_with(&lowered_prefix)
}
})
.map(|name| name.to_string())
.collect();
// Source 1.6: composite-literal candidates. Some commands
// start with a multi-token literal sequence that the lexer
// splits into Number/Punct/Identifier (e.g. `1:n` for
// `add 1:n relationship`). The parser's expected-set
// surfaces just the first token (`` `1` ``), which would
// otherwise be filtered out (not a Keyword variant). We
// surface the full composite so the user can Tab through
// without knowing the surface syntax.
let composites: Vec<String> = COMPOSITE_CANDIDATES
.iter()
.filter(|(opener, _)| expected.iter().any(|s| s == *opener))
.map(|(_, text)| (*text).to_string())
.filter(|s| matches_prefix(s))
.collect();
// Source 2: schema identifiers — accumulated across every
// matching known-set slot. `NewName` slots return `&[]`.
let mut identifiers: Vec<String> = expected
@@ -183,9 +245,15 @@ pub fn candidates_at_cursor(
// Keywords first (grammar parts read before content),
// then type names (closed-set grammar — coloured as
// keywords), then schema identifiers.
let mut candidates: Vec<Candidate> =
Vec::with_capacity(keywords.len() + type_names.len() + identifiers.len());
// keywords), then composite literals (`1:n`, …), then
// flags (own colour), then schema identifiers.
let mut candidates: Vec<Candidate> = Vec::with_capacity(
keywords.len()
+ type_names.len()
+ composites.len()
+ flags.len()
+ identifiers.len(),
);
candidates.extend(keywords.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Keyword,
@@ -194,6 +262,14 @@ pub fn candidates_at_cursor(
text,
kind: CandidateKind::Keyword,
}));
candidates.extend(composites.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Keyword,
}));
candidates.extend(flags.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Flag,
}));
candidates.extend(identifiers.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Identifier,
@@ -495,15 +571,115 @@ mod tests {
}
#[test]
fn multi_candidate_position_offers_all_options() {
// After `add ` the parser expects `1` (for 1:n) or
// `column`. Only `column` is a Keyword variant — `1`
// is a number-literal pattern. Tab on this position
// offers `column` only.
fn multi_candidate_position_offers_column_and_one_to_n() {
// After `add ` the parser expects `column` (for
// `add column ...`) and `1` (the opener for
// `add 1:n relationship ...`). The completion engine
// surfaces both: `column` straight from the keyword
// expected-set, and `1:n` as a composite literal
// candidate so the user can Tab through to the
// relationship form without knowing the surface syntax.
let cs = cands("add ", 4);
assert_eq!(cs, vec!["column".to_string()]);
assert_eq!(cs, vec!["column".to_string(), "1:n".to_string()]);
}
#[test]
fn one_to_n_filters_to_prefix_match() {
// Typed `1` after `add ` — only `1:n` matches.
let cs = cands("add 1", 5);
assert_eq!(cs, vec!["1:n".to_string()]);
}
#[test]
fn update_filter_position_offers_where_and_all_rows() {
// After `update T set Name='hi' ` the parser expects
// a `,` (more assignments), `where` (where clause),
// or `--all-rows` (flag). Punctuation isn't surfaced;
// `where` and `--all-rows` should appear.
let cs = cands("update T set Name='hi' ", 23);
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
}
#[test]
fn delete_filter_position_offers_where_and_all_rows() {
let cs = cands("delete from T ", 14);
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
}
#[test]
fn flag_candidates_are_classified_as_flag_kind() {
// Hint-panel colouring distinguishes flags from
// keywords (amber vs purple) — flags get their own
// CandidateKind so the renderer can apply tok_flag.
let kinds = candidates_at_cursor("delete from T ", 14, &SchemaCache::default())
.expect("some completion")
.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect::<Vec<_>>();
let flag = kinds
.iter()
.find(|(t, _)| t == "--all-rows")
.expect("--all-rows present");
assert_eq!(flag.1, CandidateKind::Flag);
}
#[test]
fn flag_candidates_filter_by_partial_prefix() {
let cs = cands("delete from T --", 16);
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
}
// ---- App-lifecycle command completion (round-5 fold-in) ----
#[test]
fn empty_input_offers_app_command_entry_keywords() {
let cs = cands("", 0);
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "q", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages",
] {
assert!(
cs.contains(&expected.to_string()),
"missing {expected:?} in entry-keyword candidates: {cs:?}",
);
}
}
#[test]
fn load_prefix_offers_load_only() {
let cs = cands("l", 1);
assert_eq!(cs, vec!["load".to_string()]);
}
#[test]
fn save_prefix_offers_save() {
let cs = cands("sa", 2);
assert_eq!(cs, vec!["save".to_string()]);
}
#[test]
fn mode_then_space_offers_simple_and_advanced() {
// `mode ` requires a value; the parser fails at EOF and
// the expected-set contains the two known keywords.
let cs = cands("mode ", 5);
assert!(cs.contains(&"simple".to_string()), "got {cs:?}");
assert!(cs.contains(&"advanced".to_string()), "got {cs:?}");
}
// Note: `save ` and `messages ` are deliberately NOT tested
// here. Both commands accept their bare form as a valid parse
// — `save` opens the save modal, `messages` shows the current
// verbosity — so the parser returns Ok at those positions
// and the completion engine has no expected-set to mine. The
// optional-suffix candidates (`as`, `short`, `verbose`) would
// need a separate probe mechanism (deferred — same shape as
// the post-complete-parse gap for `--create-fk` etc.).
#[test]
fn show_offers_data_and_table_alphabetised() {
let cs = cands("show ", 5);