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:
+185
-9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user