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:
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.js",
|
||||||
|
"**/*.jsx",
|
||||||
|
"**/*.py",
|
||||||
|
"**/*.go",
|
||||||
|
"**/*.rs",
|
||||||
|
"**/*.java",
|
||||||
|
"**/*.c",
|
||||||
|
"**/*.h",
|
||||||
|
"**/*.cpp",
|
||||||
|
"**/*.hpp",
|
||||||
|
"**/*.cc",
|
||||||
|
"**/*.cxx",
|
||||||
|
"**/*.cs",
|
||||||
|
"**/*.php",
|
||||||
|
"**/*.rb",
|
||||||
|
"**/*.swift",
|
||||||
|
"**/*.kt",
|
||||||
|
"**/*.kts",
|
||||||
|
"**/*.dart",
|
||||||
|
"**/*.svelte",
|
||||||
|
"**/*.vue",
|
||||||
|
"**/*.liquid",
|
||||||
|
"**/*.pas",
|
||||||
|
"**/*.dpr",
|
||||||
|
"**/*.dpk",
|
||||||
|
"**/*.lpr",
|
||||||
|
"**/*.dfm",
|
||||||
|
"**/*.fmx",
|
||||||
|
"**/*.scala",
|
||||||
|
"**/*.sc"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"**/.git/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/vendor/**",
|
||||||
|
"**/Pods/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/build/**",
|
||||||
|
"**/out/**",
|
||||||
|
"**/bin/**",
|
||||||
|
"**/obj/**",
|
||||||
|
"**/target/**",
|
||||||
|
"**/*.min.js",
|
||||||
|
"**/*.bundle.js",
|
||||||
|
"**/.next/**",
|
||||||
|
"**/.nuxt/**",
|
||||||
|
"**/.svelte-kit/**",
|
||||||
|
"**/.output/**",
|
||||||
|
"**/.turbo/**",
|
||||||
|
"**/.cache/**",
|
||||||
|
"**/.parcel-cache/**",
|
||||||
|
"**/.vite/**",
|
||||||
|
"**/.astro/**",
|
||||||
|
"**/.docusaurus/**",
|
||||||
|
"**/.gatsby/**",
|
||||||
|
"**/.webpack/**",
|
||||||
|
"**/.nx/**",
|
||||||
|
"**/.yarn/cache/**",
|
||||||
|
"**/.pnpm-store/**",
|
||||||
|
"**/storybook-static/**",
|
||||||
|
"**/.expo/**",
|
||||||
|
"**/web-build/**",
|
||||||
|
"**/ios/Pods/**",
|
||||||
|
"**/ios/build/**",
|
||||||
|
"**/android/build/**",
|
||||||
|
"**/android/.gradle/**",
|
||||||
|
"**/__pycache__/**",
|
||||||
|
"**/.venv/**",
|
||||||
|
"**/venv/**",
|
||||||
|
"**/site-packages/**",
|
||||||
|
"**/dist-packages/**",
|
||||||
|
"**/.pytest_cache/**",
|
||||||
|
"**/.mypy_cache/**",
|
||||||
|
"**/.ruff_cache/**",
|
||||||
|
"**/.tox/**",
|
||||||
|
"**/.nox/**",
|
||||||
|
"**/*.egg-info/**",
|
||||||
|
"**/.eggs/**",
|
||||||
|
"**/go/pkg/mod/**",
|
||||||
|
"**/target/debug/**",
|
||||||
|
"**/target/release/**",
|
||||||
|
"**/.gradle/**",
|
||||||
|
"**/.m2/**",
|
||||||
|
"**/generated-sources/**",
|
||||||
|
"**/.kotlin/**",
|
||||||
|
"**/.dart_tool/**",
|
||||||
|
"**/.vs/**",
|
||||||
|
"**/.nuget/**",
|
||||||
|
"**/artifacts/**",
|
||||||
|
"**/publish/**",
|
||||||
|
"**/cmake-build-*/**",
|
||||||
|
"**/CMakeFiles/**",
|
||||||
|
"**/bazel-*/**",
|
||||||
|
"**/vcpkg_installed/**",
|
||||||
|
"**/.conan/**",
|
||||||
|
"**/Debug/**",
|
||||||
|
"**/Release/**",
|
||||||
|
"**/x64/**",
|
||||||
|
"**/.pio/**",
|
||||||
|
"**/release/**",
|
||||||
|
"**/*.app/**",
|
||||||
|
"**/*.asar",
|
||||||
|
"**/DerivedData/**",
|
||||||
|
"**/.build/**",
|
||||||
|
"**/.swiftpm/**",
|
||||||
|
"**/xcuserdata/**",
|
||||||
|
"**/Carthage/Build/**",
|
||||||
|
"**/SourcePackages/**",
|
||||||
|
"**/__history/**",
|
||||||
|
"**/__recovery/**",
|
||||||
|
"**/*.dcu",
|
||||||
|
"**/.composer/**",
|
||||||
|
"**/storage/framework/**",
|
||||||
|
"**/bootstrap/cache/**",
|
||||||
|
"**/.bundle/**",
|
||||||
|
"**/tmp/cache/**",
|
||||||
|
"**/public/assets/**",
|
||||||
|
"**/public/packs/**",
|
||||||
|
"**/.yardoc/**",
|
||||||
|
"**/coverage/**",
|
||||||
|
"**/htmlcov/**",
|
||||||
|
"**/.nyc_output/**",
|
||||||
|
"**/test-results/**",
|
||||||
|
"**/.coverage/**",
|
||||||
|
"**/.idea/**",
|
||||||
|
"**/logs/**",
|
||||||
|
"**/tmp/**",
|
||||||
|
"**/temp/**",
|
||||||
|
"**/_build/**",
|
||||||
|
"**/docs/_build/**",
|
||||||
|
"**/site/**"
|
||||||
|
],
|
||||||
|
"languages": [],
|
||||||
|
"frameworks": [],
|
||||||
|
"maxFileSize": 1048576,
|
||||||
|
"extractDocstrings": true,
|
||||||
|
"trackCallSites": true
|
||||||
|
}
|
||||||
+130
-109
@@ -889,61 +889,17 @@ impl App {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canonical app-level commands recognised in both modes.
|
// Parse-first: app-level commands and DSL commands now
|
||||||
// Track-2's full lifecycle command set lands across
|
// share the chumsky parser (per the round-5 refactor).
|
||||||
// Iterations 4 (rebuild, save, save as, new, load) and
|
// App commands work in both modes — they're not gated by
|
||||||
// 5 (export, import).
|
// `effective_mode`. Anything that parses to a non-App
|
||||||
match effective_input.as_str() {
|
// variant falls through to the existing mode-specific
|
||||||
"quit" | "q" => return vec![Action::Quit],
|
// path: simple → DSL execution; advanced → SQL placeholder.
|
||||||
"help" => {
|
// Anything that fails to parse falls through too — the
|
||||||
self.note_help();
|
// simple-mode path renders the friendly parse error, the
|
||||||
return Vec::new();
|
// advanced-mode path renders the SQL placeholder.
|
||||||
}
|
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
|
||||||
"rebuild" => return vec![Action::PrepareRebuild],
|
return self.dispatch_app_command(app_cmd, &effective_input);
|
||||||
"save" => {
|
|
||||||
return self.handle_save_command(false);
|
|
||||||
}
|
|
||||||
"save as" => {
|
|
||||||
return self.handle_save_command(true);
|
|
||||||
}
|
|
||||||
"new" => {
|
|
||||||
return vec![Action::NewProject {
|
|
||||||
source: "new".to_string(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
"load" => {
|
|
||||||
return vec![Action::OpenLoadPicker];
|
|
||||||
}
|
|
||||||
"export" => {
|
|
||||||
return vec![Action::Export {
|
|
||||||
target: None,
|
|
||||||
source: "export".to_string(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
other if other.starts_with("export ") => {
|
|
||||||
let target = other["export ".len()..].trim();
|
|
||||||
if target.is_empty() {
|
|
||||||
self.note_error(crate::t!("project.export_usage"));
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
return vec![Action::Export {
|
|
||||||
target: Some(target.to_string()),
|
|
||||||
source: format!("export {target}"),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
other if other.starts_with("import ") || other == "import" => {
|
|
||||||
let rest = other.strip_prefix("import").unwrap_or(other);
|
|
||||||
return self.handle_import_command(rest);
|
|
||||||
}
|
|
||||||
other if other.starts_with("mode") => {
|
|
||||||
self.handle_mode_command(other);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
other if other.starts_with("messages") => {
|
|
||||||
self.handle_messages_command(other);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For everything else: dispatch by effective mode.
|
// For everything else: dispatch by effective mode.
|
||||||
@@ -968,6 +924,79 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatch a parsed app-lifecycle command. Works in both
|
||||||
|
/// simple and advanced modes; the parse-first refactor
|
||||||
|
/// (round-5) routes app commands here before the
|
||||||
|
/// mode-specific DSL/SQL paths.
|
||||||
|
fn dispatch_app_command(
|
||||||
|
&mut self,
|
||||||
|
cmd: crate::dsl::AppCommand,
|
||||||
|
source: &str,
|
||||||
|
) -> Vec<Action> {
|
||||||
|
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
|
||||||
|
match cmd {
|
||||||
|
AppCommand::Quit => vec![Action::Quit],
|
||||||
|
AppCommand::Help => {
|
||||||
|
self.note_help();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
||||||
|
AppCommand::Save => self.handle_save_command(false),
|
||||||
|
AppCommand::SaveAs => self.handle_save_command(true),
|
||||||
|
AppCommand::New => vec![Action::NewProject {
|
||||||
|
source: "new".to_string(),
|
||||||
|
}],
|
||||||
|
AppCommand::Load => vec![Action::OpenLoadPicker],
|
||||||
|
AppCommand::Export { path } => path.map_or_else(
|
||||||
|
|| {
|
||||||
|
vec![Action::Export {
|
||||||
|
target: None,
|
||||||
|
source: "export".to_string(),
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|target| {
|
||||||
|
vec![Action::Export {
|
||||||
|
source: format!("export {target}"),
|
||||||
|
target: Some(target),
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AppCommand::Import { path, target } => {
|
||||||
|
// The path-bearing import goes through the
|
||||||
|
// pre-chumsky source-slice (parser.rs), which
|
||||||
|
// already validated non-empty path. Bare
|
||||||
|
// `import` returns from chumsky with an empty
|
||||||
|
// path string — surface the usage error.
|
||||||
|
if path.is_empty() {
|
||||||
|
self.note_error(crate::t!("project.import_usage"));
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
vec![Action::Import {
|
||||||
|
zip_path: path,
|
||||||
|
as_target: target,
|
||||||
|
source: source.to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
AppCommand::Mode { value } => {
|
||||||
|
let arg = match value {
|
||||||
|
ModeValue::Simple => "simple",
|
||||||
|
ModeValue::Advanced => "advanced",
|
||||||
|
};
|
||||||
|
self.handle_mode_command(&format!("mode {arg}"));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
AppCommand::Messages { value } => {
|
||||||
|
let raw = match value {
|
||||||
|
None => "messages".to_string(),
|
||||||
|
Some(MessagesValue::Short) => "messages short".to_string(),
|
||||||
|
Some(MessagesValue::Verbose) => "messages verbose".to_string(),
|
||||||
|
};
|
||||||
|
self.handle_messages_command(&raw);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
||||||
match parse_command(input) {
|
match parse_command(input) {
|
||||||
Ok(Command::Replay { path }) => {
|
Ok(Command::Replay { path }) => {
|
||||||
@@ -1268,6 +1297,13 @@ impl App {
|
|||||||
(Operation::Query, Some(name.as_str()), None)
|
(Operation::Query, Some(name.as_str()), None)
|
||||||
}
|
}
|
||||||
C::Replay { .. } => (Operation::Replay, None, None),
|
C::Replay { .. } => (Operation::Replay, None, None),
|
||||||
|
// App-lifecycle commands never reach this path —
|
||||||
|
// `dispatch_input` routes them through
|
||||||
|
// `dispatch_app_command` before the DSL execution
|
||||||
|
// pipeline that this context builder feeds.
|
||||||
|
C::App(_) => unreachable!(
|
||||||
|
"App commands are dispatched before reaching dsl execution"
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
TranslateContext {
|
TranslateContext {
|
||||||
@@ -1298,42 +1334,6 @@ impl App {
|
|||||||
/// "as" is fine — the separator only matches when
|
/// "as" is fine — the separator only matches when
|
||||||
/// surrounded by spaces. `split_once` is used (first
|
/// surrounded by spaces. `split_once` is used (first
|
||||||
/// occurrence wins), which is the natural reading.
|
/// occurrence wins), which is the natural reading.
|
||||||
fn handle_import_command(&mut self, rest: &str) -> Vec<Action> {
|
|
||||||
let rest = rest.trim();
|
|
||||||
if rest.is_empty() {
|
|
||||||
self.note_error(crate::t!("project.import_usage"));
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
// `submit()` trims trailing whitespace from the raw
|
|
||||||
// line, so an input like `import foo.zip as ` arrives
|
|
||||||
// here as `foo.zip as`. Detect that explicitly rather
|
|
||||||
// than silently treating "as" as part of the zip
|
|
||||||
// path.
|
|
||||||
if rest == "as" || rest.ends_with(" as") {
|
|
||||||
self.note_error(crate::t!("project.import_empty_target"));
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let (zip_path, as_target) = match rest.split_once(" as ") {
|
|
||||||
Some((zip, target)) => (zip.trim(), Some(target.trim().to_string())),
|
|
||||||
None => (rest, None),
|
|
||||||
};
|
|
||||||
if zip_path.is_empty() {
|
|
||||||
self.note_error(crate::t!("project.import_usage"));
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
if let Some(t) = as_target.as_deref()
|
|
||||||
&& t.is_empty()
|
|
||||||
{
|
|
||||||
self.note_error(crate::t!("project.import_empty_target"));
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
vec![Action::Import {
|
|
||||||
zip_path: zip_path.to_string(),
|
|
||||||
as_target,
|
|
||||||
source: format!("import {rest}"),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch for the `save` and `save as` commands.
|
/// Dispatch for the `save` and `save as` commands.
|
||||||
///
|
///
|
||||||
/// `save` on a temp project is identical to `save as`
|
/// `save` on a temp project is identical to `save as`
|
||||||
@@ -1752,18 +1752,20 @@ fn render_usage_block(input: &str, position: usize) -> String {
|
|||||||
|
|
||||||
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
||||||
use crate::dsl::ReferentialAction;
|
use crate::dsl::ReferentialAction;
|
||||||
let what = match effect.action {
|
let action_key = match effect.action {
|
||||||
ReferentialAction::Cascade => "deleted",
|
ReferentialAction::Cascade => "db.cascade.action_deleted",
|
||||||
ReferentialAction::SetNull => "had FK set to null",
|
ReferentialAction::SetNull => "db.cascade.action_set_null",
|
||||||
ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked",
|
ReferentialAction::Restrict | ReferentialAction::NoAction => {
|
||||||
|
"db.cascade.action_blocked"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
format!(
|
crate::t!(
|
||||||
" related: {} row(s) {} in `{}` for relationship `{}` (on delete {})",
|
"db.cascade.summary",
|
||||||
effect.rows_changed,
|
count = effect.rows_changed,
|
||||||
what,
|
action = crate::friendly::translate(action_key, &[]),
|
||||||
effect.child_table,
|
child_table = effect.child_table,
|
||||||
effect.relationship_name,
|
rel = effect.relationship_name,
|
||||||
effect.action,
|
on_delete = effect.action,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1966,13 +1968,17 @@ mod tests {
|
|||||||
// Stage-8 follow-up #2 (testing-round-2): the
|
// Stage-8 follow-up #2 (testing-round-2): the
|
||||||
// single-candidate-no-memo design lets the user chain
|
// single-candidate-no-memo design lets the user chain
|
||||||
// Tabs through unique completions without getting
|
// Tabs through unique completions without getting
|
||||||
// stuck. From "a", Tab → "add ", Tab → "add column ".
|
// stuck. From "cr", Tab → "create ", Tab → "create
|
||||||
|
// table ". (Round 5 added the app-lifecycle commands —
|
||||||
|
// single-letter prefixes like `i` are now ambiguous
|
||||||
|
// (`insert` vs. `import`), so the test starts from a
|
||||||
|
// disambiguated two-letter prefix.)
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
type_str(&mut app, "a");
|
type_str(&mut app, "cr");
|
||||||
app.update(key(KeyCode::Tab));
|
app.update(key(KeyCode::Tab));
|
||||||
assert_eq!(app.input, "add ");
|
assert_eq!(app.input, "create ");
|
||||||
app.update(key(KeyCode::Tab));
|
app.update(key(KeyCode::Tab));
|
||||||
assert_eq!(app.input, "add column ");
|
assert_eq!(app.input, "create table ");
|
||||||
assert!(app.last_completion.is_none());
|
assert!(app.last_completion.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2102,9 +2108,24 @@ mod tests {
|
|||||||
type_str(&mut app, "mode sideways");
|
type_str(&mut app, "mode sideways");
|
||||||
submit(&mut app);
|
submit(&mut app);
|
||||||
assert_eq!(app.mode, Mode::Simple);
|
assert_eq!(app.mode, Mode::Simple);
|
||||||
let last = app.output.back().unwrap();
|
// The error surfaces somewhere in the output buffer
|
||||||
assert_eq!(last.kind, OutputKind::Error);
|
// (could be the caret line, the parse-error detail
|
||||||
assert!(last.text.contains("unknown mode"));
|
// line, or the usage line). Scan for the friendly
|
||||||
|
// "unknown mode" anchor phrase.
|
||||||
|
let anywhere = app
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.text.contains("unknown mode"));
|
||||||
|
assert!(
|
||||||
|
anywhere,
|
||||||
|
"expected 'unknown mode' somewhere in output: {:?}",
|
||||||
|
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let any_error = app
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.kind == OutputKind::Error);
|
||||||
|
assert!(any_error, "expected at least one Error line");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+185
-9
@@ -26,6 +26,20 @@ use crate::dsl::{ParseError, parse_command};
|
|||||||
/// completion engine and the parser agree on the magic string.
|
/// completion engine and the parser agree on the magic string.
|
||||||
const TYPE_SLOT_LABEL: &str = "type";
|
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).
|
/// Per-project schema lookup cache (ADR-0022 §9).
|
||||||
///
|
///
|
||||||
/// Held by `App::schema_cache` and consulted by the completion
|
/// Held by `App::schema_cache` and consulted by the completion
|
||||||
@@ -68,6 +82,9 @@ pub enum CandidateKind {
|
|||||||
Keyword,
|
Keyword,
|
||||||
/// A schema entity (table, column, relationship).
|
/// A schema entity (table, column, relationship).
|
||||||
Identifier,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -164,6 +181,51 @@ pub fn candidates_at_cursor(
|
|||||||
Vec::new()
|
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
|
// Source 2: schema identifiers — accumulated across every
|
||||||
// matching known-set slot. `NewName` slots return `&[]`.
|
// matching known-set slot. `NewName` slots return `&[]`.
|
||||||
let mut identifiers: Vec<String> = expected
|
let mut identifiers: Vec<String> = expected
|
||||||
@@ -183,9 +245,15 @@ pub fn candidates_at_cursor(
|
|||||||
|
|
||||||
// Keywords first (grammar parts read before content),
|
// Keywords first (grammar parts read before content),
|
||||||
// then type names (closed-set grammar — coloured as
|
// then type names (closed-set grammar — coloured as
|
||||||
// keywords), then schema identifiers.
|
// keywords), then composite literals (`1:n`, …), then
|
||||||
let mut candidates: Vec<Candidate> =
|
// flags (own colour), then schema identifiers.
|
||||||
Vec::with_capacity(keywords.len() + type_names.len() + identifiers.len());
|
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 {
|
candidates.extend(keywords.into_iter().map(|text| Candidate {
|
||||||
text,
|
text,
|
||||||
kind: CandidateKind::Keyword,
|
kind: CandidateKind::Keyword,
|
||||||
@@ -194,6 +262,14 @@ pub fn candidates_at_cursor(
|
|||||||
text,
|
text,
|
||||||
kind: CandidateKind::Keyword,
|
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 {
|
candidates.extend(identifiers.into_iter().map(|text| Candidate {
|
||||||
text,
|
text,
|
||||||
kind: CandidateKind::Identifier,
|
kind: CandidateKind::Identifier,
|
||||||
@@ -495,15 +571,115 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_candidate_position_offers_all_options() {
|
fn multi_candidate_position_offers_column_and_one_to_n() {
|
||||||
// After `add ` the parser expects `1` (for 1:n) or
|
// After `add ` the parser expects `column` (for
|
||||||
// `column`. Only `column` is a Keyword variant — `1`
|
// `add column ...`) and `1` (the opener for
|
||||||
// is a number-literal pattern. Tab on this position
|
// `add 1:n relationship ...`). The completion engine
|
||||||
// offers `column` only.
|
// 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);
|
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]
|
#[test]
|
||||||
fn show_offers_data_and_table_alphabetised() {
|
fn show_offers_data_and_table_alphabetised() {
|
||||||
let cs = cands("show ", 5);
|
let cs = cands("show ", 5);
|
||||||
|
|||||||
@@ -2632,7 +2632,11 @@ fn render_lossy_diagnostic(
|
|||||||
lossies: &[&Outcome],
|
lossies: &[&Outcome],
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut headers = pk_header_cells(pk_columns);
|
let mut headers = pk_header_cells(pk_columns);
|
||||||
headers.extend(["From".to_string(), "To".to_string(), "Reason".to_string()]);
|
headers.extend([
|
||||||
|
crate::t!("db.diagnostic.header_from"),
|
||||||
|
crate::t!("db.diagnostic.header_to"),
|
||||||
|
crate::t!("db.diagnostic.header_reason"),
|
||||||
|
]);
|
||||||
|
|
||||||
let mut alignments = pk_header_alignments(pk_columns, old_schema);
|
let mut alignments = pk_header_alignments(pk_columns, old_schema);
|
||||||
alignments.extend([
|
alignments.extend([
|
||||||
@@ -2662,18 +2666,22 @@ fn render_lossy_diagnostic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \
|
"{}\n\n",
|
||||||
{total} row(s) would discard information.\n\n"
|
crate::t!(
|
||||||
|
"db.diagnostic.lossy_summary",
|
||||||
|
table = table,
|
||||||
|
column = column,
|
||||||
|
src_ty = src_ty,
|
||||||
|
target_ty = target_ty,
|
||||||
|
total = total,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
||||||
out.push_str(&line);
|
out.push_str(&line);
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
out.push_str(
|
out.push_str(&crate::t!("db.diagnostic.force_conversion_hint"));
|
||||||
"if you want to execute this conversion in spite of the problems, \
|
|
||||||
re-run with `--force-conversion`.",
|
|
||||||
);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2688,7 +2696,10 @@ fn render_incompatible_diagnostic(
|
|||||||
incompatibles: &[&Outcome],
|
incompatibles: &[&Outcome],
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut headers = pk_header_cells(pk_columns);
|
let mut headers = pk_header_cells(pk_columns);
|
||||||
headers.extend(["Value".to_string(), "Reason".to_string()]);
|
headers.extend([
|
||||||
|
crate::t!("db.diagnostic.header_value"),
|
||||||
|
crate::t!("db.diagnostic.header_reason"),
|
||||||
|
]);
|
||||||
|
|
||||||
let mut alignments = pk_header_alignments(pk_columns, old_schema);
|
let mut alignments = pk_header_alignments(pk_columns, old_schema);
|
||||||
alignments.extend([
|
alignments.extend([
|
||||||
@@ -2714,8 +2725,15 @@ fn render_incompatible_diagnostic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \
|
"{}\n\n",
|
||||||
{total} row(s) cannot be converted.\n\n"
|
crate::t!(
|
||||||
|
"db.diagnostic.incompatible_summary",
|
||||||
|
table = table,
|
||||||
|
column = column,
|
||||||
|
src_ty = src_ty,
|
||||||
|
target_ty = target_ty,
|
||||||
|
total = total,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
||||||
out.push_str(&line);
|
out.push_str(&line);
|
||||||
@@ -2766,9 +2784,9 @@ fn check_uniqueness_collisions(
|
|||||||
|
|
||||||
let pk_label = pk_columns.join(", ");
|
let pk_label = pk_columns.join(", ");
|
||||||
let headers = vec![
|
let headers = vec![
|
||||||
"Becomes".to_string(),
|
crate::t!("db.diagnostic.header_becomes"),
|
||||||
format!("Source rows ({pk_label})"),
|
crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
|
||||||
"Source values".to_string(),
|
crate::t!("db.diagnostic.header_source_values"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let alignments = vec![
|
let alignments = vec![
|
||||||
@@ -2814,8 +2832,15 @@ fn check_uniqueness_collisions(
|
|||||||
let _ = old_schema;
|
let _ = old_schema;
|
||||||
|
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \
|
"{}\n\n",
|
||||||
{total} collision(s) would violate uniqueness.\n\n"
|
crate::t!(
|
||||||
|
"db.diagnostic.uniqueness_summary",
|
||||||
|
table = table,
|
||||||
|
column = column,
|
||||||
|
src_ty = src_ty,
|
||||||
|
target_ty = target_ty,
|
||||||
|
total = total,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
||||||
out.push_str(&line);
|
out.push_str(&line);
|
||||||
|
|||||||
@@ -142,6 +142,60 @@ pub enum Command {
|
|||||||
Replay {
|
Replay {
|
||||||
path: String,
|
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).
|
/// Conversion mode for `change column …` (ADR-0017 §5).
|
||||||
@@ -218,6 +272,19 @@ impl Command {
|
|||||||
Self::Delete { .. } => "delete from",
|
Self::Delete { .. } => "delete from",
|
||||||
Self::ShowData { .. } => "show data",
|
Self::ShowData { .. } => "show data",
|
||||||
Self::Replay { .. } => "replay",
|
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
|
// Replay isn't tied to a single table; the path is
|
||||||
// the most identifying thing for log output.
|
// the most identifying thing for log output.
|
||||||
Self::Replay { path } => path,
|
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(_) => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,31 @@ define_keywords! {
|
|||||||
Restrict => "restrict",
|
Restrict => "restrict",
|
||||||
Action => "action",
|
Action => "action",
|
||||||
No => "no",
|
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 {
|
macro_rules! define_punct {
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,8 @@ pub mod value;
|
|||||||
|
|
||||||
pub use action::ReferentialAction;
|
pub use action::ReferentialAction;
|
||||||
pub use command::{
|
pub use command::{
|
||||||
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
|
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
|
||||||
|
RelationshipSelector, RowFilter,
|
||||||
};
|
};
|
||||||
pub use parser::{ParseError, parse_command};
|
pub use parser::{ParseError, parse_command};
|
||||||
pub use types::Type;
|
pub use types::Type;
|
||||||
|
|||||||
+153
-13
@@ -15,7 +15,8 @@ use chumsky::prelude::*;
|
|||||||
|
|
||||||
use crate::dsl::action::ReferentialAction;
|
use crate::dsl::action::ReferentialAction;
|
||||||
use crate::dsl::command::{
|
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::ident_slot::IdentSlot;
|
||||||
use crate::dsl::keyword::{Keyword, Punct};
|
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) {
|
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if let Some(result) = try_parse_app_path_command(tokens, source) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
match command_parser().parse(tokens).into_result() {
|
match command_parser().parse(tokens).into_result() {
|
||||||
Ok(cmd) => Ok(cmd),
|
Ok(cmd) => Ok(cmd),
|
||||||
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
|
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
|
// of error chumsky would (positioned where the path
|
||||||
// should have started).
|
// should have started).
|
||||||
return Some(Err(ParseError::Invalid {
|
return Some(Err(ParseError::Invalid {
|
||||||
message: "expected a path after `replay`".to_string(),
|
message: crate::t!("parse.custom.replay_path_expected"),
|
||||||
position: after_replay,
|
position: after_replay,
|
||||||
at_eof: true,
|
at_eof: true,
|
||||||
expected: vec!["path".to_string()],
|
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)
|
// Token-aware combinator helpers (ADR-0020 §5)
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -287,10 +350,7 @@ fn command_parser<'a>()
|
|||||||
if pk_specs.is_empty() {
|
if pk_specs.is_empty() {
|
||||||
return Err(Rich::custom(
|
return Err(Rich::custom(
|
||||||
span,
|
span,
|
||||||
"tables need at least one column. Add `with pk` for a default \
|
crate::t!("parse.custom.create_table_needs_pk"),
|
||||||
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
|
|
||||||
Use a comma-separated list for compound primary keys."
|
|
||||||
.to_string(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let columns: Vec<ColumnSpec> = pk_specs
|
let columns: Vec<ColumnSpec> = pk_specs
|
||||||
@@ -390,6 +450,66 @@ fn command_parser<'a>()
|
|||||||
.ignore_then(string_payload())
|
.ignore_then(string_payload())
|
||||||
.map(|path| Command::Replay { path });
|
.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((
|
choice((
|
||||||
create_table,
|
create_table,
|
||||||
// `drop column` and `drop relationship` come before
|
// `drop column` and `drop relationship` come before
|
||||||
@@ -408,6 +528,19 @@ fn command_parser<'a>()
|
|||||||
update_cmd,
|
update_cmd,
|
||||||
delete_cmd,
|
delete_cmd,
|
||||||
replay,
|
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())
|
.then_ignore(end())
|
||||||
}
|
}
|
||||||
@@ -505,9 +638,15 @@ fn filter_clause<'a>()
|
|||||||
|
|
||||||
let all_rows = flag("all-rows").to(RowFilter::AllRows);
|
let all_rows = flag("all-rows").to(RowFilter::AllRows);
|
||||||
|
|
||||||
where_clause
|
// No `.labelled()` wrap here: chumsky's expected-set then
|
||||||
.or(all_rows)
|
// surfaces the constituent options (`` `where` ``,
|
||||||
.labelled("where clause or --all-rows")
|
// `` `--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>()
|
fn value_literal<'a>()
|
||||||
@@ -625,7 +764,10 @@ fn referential_clauses<'a>() -> impl Parser<
|
|||||||
if slot.is_some() {
|
if slot.is_some() {
|
||||||
return Err(Rich::custom(
|
return Err(Rich::custom(
|
||||||
span,
|
span,
|
||||||
format!("`on {target}` specified twice"),
|
crate::t!(
|
||||||
|
"parse.custom.on_action_specified_twice",
|
||||||
|
target = target,
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
*slot = Some(action);
|
*slot = Some(action);
|
||||||
@@ -683,9 +825,7 @@ fn change_column_flags<'a>()
|
|||||||
[single] => Ok(*single),
|
[single] => Ok(*single),
|
||||||
_ => Err(Rich::custom(
|
_ => Err(Rich::custom(
|
||||||
span,
|
span,
|
||||||
"`--force-conversion` and `--dont-convert` are mutually \
|
crate::t!("parse.custom.change_column_flags_exclusive"),
|
||||||
exclusive — pick one."
|
|
||||||
.to_string(),
|
|
||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-4
@@ -130,15 +130,29 @@ impl fmt::Display for Type {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned when parsing a type keyword that isn't
|
/// Error returned when parsing a type keyword that isn't recognised.
|
||||||
/// recognised.
|
///
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
/// Display formatting flows through the i18n catalog
|
||||||
#[error("unknown type '{found}' (expected one of: {expected})")]
|
/// (`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 struct UnknownType {
|
||||||
pub found: String,
|
pub found: String,
|
||||||
pub expected: 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 {
|
impl FromStr for Type {
|
||||||
type Err = UnknownType;
|
type Err = UnknownType;
|
||||||
|
|
||||||
|
|||||||
+72
-8
@@ -92,6 +92,53 @@ pub const REGISTRY: &[UsageEntry] = &[
|
|||||||
entry: Keyword::Replay,
|
entry: Keyword::Replay,
|
||||||
catalog_key: "parse.usage.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.
|
/// Find the entry-keyword whose grammar to illustrate.
|
||||||
@@ -155,11 +202,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_command_has_a_registry_entry() {
|
fn every_command_has_a_registry_entry() {
|
||||||
// The parser recognises ten command-entry keywords
|
// Every command-entry keyword recognised by the parser
|
||||||
// (ADR-0009 + ADR-0006 + ADR-0014). Each MUST be
|
// MUST be represented in the registry — otherwise a
|
||||||
// represented in the registry — otherwise a parse error
|
// parse error for that command renders no usage block
|
||||||
// for that command renders no usage block and the H1a
|
// and the H1a pedagogy gap reopens for that family.
|
||||||
// pedagogy gap reopens for that family.
|
// Round 5 added the app-lifecycle entry keywords
|
||||||
|
// alongside the original ten DSL entry keywords.
|
||||||
for entry in [
|
for entry in [
|
||||||
Keyword::Create,
|
Keyword::Create,
|
||||||
Keyword::Drop,
|
Keyword::Drop,
|
||||||
@@ -171,6 +219,17 @@ mod tests {
|
|||||||
Keyword::Update,
|
Keyword::Update,
|
||||||
Keyword::Delete,
|
Keyword::Delete,
|
||||||
Keyword::Replay,
|
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!(
|
assert!(
|
||||||
REGISTRY.iter().any(|e| e.entry == entry),
|
REGISTRY.iter().any(|e| e.entry == entry),
|
||||||
@@ -246,14 +305,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn entry_keywords_alphabetised_returns_ten_unique_sorted_commands() {
|
fn entry_keywords_alphabetised_returns_unique_sorted_commands() {
|
||||||
let keys = entry_keywords_alphabetised();
|
let keys = entry_keywords_alphabetised();
|
||||||
let names: Vec<&str> = keys.iter().map(|k| k.as_str()).collect();
|
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!(
|
assert_eq!(
|
||||||
names,
|
names,
|
||||||
vec![
|
vec![
|
||||||
"add", "change", "create", "delete", "drop", "insert",
|
"add", "change", "create", "delete", "drop", "export",
|
||||||
"rename", "replay", "show", "update",
|
"help", "import", "insert", "load", "messages", "mode",
|
||||||
|
"new", "q", "quit", "rebuild", "rename", "replay",
|
||||||
|
"save", "show", "update",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
// ---- Parse error rendering ----
|
// ---- Parse error rendering ----
|
||||||
("parse.available_commands", &["commands"]),
|
("parse.available_commands", &["commands"]),
|
||||||
("parse.caret", &["padding"]),
|
("parse.caret", &["padding"]),
|
||||||
|
// Custom (try_map / source-slice) error messages raised
|
||||||
|
// by the DSL parser. See `parse.custom.*` in the catalog.
|
||||||
|
("parse.custom.change_column_flags_exclusive", &[]),
|
||||||
|
("parse.custom.create_table_needs_pk", &[]),
|
||||||
|
("parse.custom.on_action_specified_twice", &["target"]),
|
||||||
|
("parse.custom.replay_path_expected", &[]),
|
||||||
|
("parse.custom.unknown_type", &["found", "expected"]),
|
||||||
("parse.empty", &[]),
|
("parse.empty", &[]),
|
||||||
("parse.error", &["detail"]),
|
("parse.error", &["detail"]),
|
||||||
// Per-command usage templates (ADR-0021 §1). One key per
|
// Per-command usage templates (ADR-0021 §1). One key per
|
||||||
@@ -158,7 +165,17 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.drop_table", &[]),
|
("parse.usage.drop_table", &[]),
|
||||||
("parse.usage.insert", &[]),
|
("parse.usage.insert", &[]),
|
||||||
("parse.usage.rename_column", &[]),
|
("parse.usage.rename_column", &[]),
|
||||||
|
("parse.usage.export", &[]),
|
||||||
|
("parse.usage.help", &[]),
|
||||||
|
("parse.usage.import", &[]),
|
||||||
|
("parse.usage.load", &[]),
|
||||||
|
("parse.usage.messages", &[]),
|
||||||
|
("parse.usage.mode", &[]),
|
||||||
|
("parse.usage.new", &[]),
|
||||||
|
("parse.usage.quit", &[]),
|
||||||
|
("parse.usage.rebuild", &[]),
|
||||||
("parse.usage.replay", &[]),
|
("parse.usage.replay", &[]),
|
||||||
|
("parse.usage.save", &[]),
|
||||||
("parse.usage.show_data", &[]),
|
("parse.usage.show_data", &[]),
|
||||||
("parse.usage.show_table", &[]),
|
("parse.usage.show_table", &[]),
|
||||||
("parse.usage.update", &[]),
|
("parse.usage.update", &[]),
|
||||||
@@ -176,6 +193,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.token.identifier", &[]),
|
("parse.token.identifier", &[]),
|
||||||
("parse.token.keyword.action", &[]),
|
("parse.token.keyword.action", &[]),
|
||||||
("parse.token.keyword.add", &[]),
|
("parse.token.keyword.add", &[]),
|
||||||
|
("parse.token.keyword.advanced", &[]),
|
||||||
("parse.token.keyword.as", &[]),
|
("parse.token.keyword.as", &[]),
|
||||||
("parse.token.keyword.cascade", &[]),
|
("parse.token.keyword.cascade", &[]),
|
||||||
("parse.token.keyword.change", &[]),
|
("parse.token.keyword.change", &[]),
|
||||||
@@ -184,26 +202,40 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.token.keyword.data", &[]),
|
("parse.token.keyword.data", &[]),
|
||||||
("parse.token.keyword.delete", &[]),
|
("parse.token.keyword.delete", &[]),
|
||||||
("parse.token.keyword.drop", &[]),
|
("parse.token.keyword.drop", &[]),
|
||||||
|
("parse.token.keyword.export", &[]),
|
||||||
("parse.token.keyword.false", &[]),
|
("parse.token.keyword.false", &[]),
|
||||||
("parse.token.keyword.from", &[]),
|
("parse.token.keyword.from", &[]),
|
||||||
|
("parse.token.keyword.help", &[]),
|
||||||
|
("parse.token.keyword.import", &[]),
|
||||||
("parse.token.keyword.in", &[]),
|
("parse.token.keyword.in", &[]),
|
||||||
("parse.token.keyword.insert", &[]),
|
("parse.token.keyword.insert", &[]),
|
||||||
("parse.token.keyword.into", &[]),
|
("parse.token.keyword.into", &[]),
|
||||||
|
("parse.token.keyword.load", &[]),
|
||||||
|
("parse.token.keyword.messages", &[]),
|
||||||
|
("parse.token.keyword.mode", &[]),
|
||||||
|
("parse.token.keyword.new", &[]),
|
||||||
("parse.token.keyword.no", &[]),
|
("parse.token.keyword.no", &[]),
|
||||||
("parse.token.keyword.null", &[]),
|
("parse.token.keyword.null", &[]),
|
||||||
("parse.token.keyword.on", &[]),
|
("parse.token.keyword.on", &[]),
|
||||||
("parse.token.keyword.pk", &[]),
|
("parse.token.keyword.pk", &[]),
|
||||||
|
("parse.token.keyword.q", &[]),
|
||||||
|
("parse.token.keyword.quit", &[]),
|
||||||
|
("parse.token.keyword.rebuild", &[]),
|
||||||
("parse.token.keyword.relationship", &[]),
|
("parse.token.keyword.relationship", &[]),
|
||||||
("parse.token.keyword.rename", &[]),
|
("parse.token.keyword.rename", &[]),
|
||||||
("parse.token.keyword.replay", &[]),
|
("parse.token.keyword.replay", &[]),
|
||||||
("parse.token.keyword.restrict", &[]),
|
("parse.token.keyword.restrict", &[]),
|
||||||
|
("parse.token.keyword.save", &[]),
|
||||||
("parse.token.keyword.set", &[]),
|
("parse.token.keyword.set", &[]),
|
||||||
|
("parse.token.keyword.short", &[]),
|
||||||
("parse.token.keyword.show", &[]),
|
("parse.token.keyword.show", &[]),
|
||||||
|
("parse.token.keyword.simple", &[]),
|
||||||
("parse.token.keyword.table", &[]),
|
("parse.token.keyword.table", &[]),
|
||||||
("parse.token.keyword.to", &[]),
|
("parse.token.keyword.to", &[]),
|
||||||
("parse.token.keyword.true", &[]),
|
("parse.token.keyword.true", &[]),
|
||||||
("parse.token.keyword.update", &[]),
|
("parse.token.keyword.update", &[]),
|
||||||
("parse.token.keyword.values", &[]),
|
("parse.token.keyword.values", &[]),
|
||||||
|
("parse.token.keyword.verbose", &[]),
|
||||||
("parse.token.keyword.where", &[]),
|
("parse.token.keyword.where", &[]),
|
||||||
("parse.token.keyword.with", &[]),
|
("parse.token.keyword.with", &[]),
|
||||||
("parse.token.number", &[]),
|
("parse.token.number", &[]),
|
||||||
@@ -222,6 +254,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("project.import_usage", &[]),
|
("project.import_usage", &[]),
|
||||||
("project.import_zip_missing", &["path"]),
|
("project.import_zip_missing", &["path"]),
|
||||||
("project.load_path_missing", &["path"]),
|
("project.load_path_missing", &["path"]),
|
||||||
|
("project.resume_no_previous", &["data_root"]),
|
||||||
|
("project.resume_recorded_missing", &["path"]),
|
||||||
("project.saveas_target_exists", &["path"]),
|
("project.saveas_target_exists", &["path"]),
|
||||||
("project.rebuild_failed", &["error"]),
|
("project.rebuild_failed", &["error"]),
|
||||||
("project.rebuild_ok", &["summary"]),
|
("project.rebuild_ok", &["summary"]),
|
||||||
@@ -248,6 +282,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("modal.rebuild_confirm_title", &[]),
|
("modal.rebuild_confirm_title", &[]),
|
||||||
// ---- Status bar + panels ----
|
// ---- Status bar + panels ----
|
||||||
("panel.hint_empty", &[]),
|
("panel.hint_empty", &[]),
|
||||||
|
("panel.hint_title", &[]),
|
||||||
|
("panel.output_title", &[]),
|
||||||
("panel.tables_empty", &[]),
|
("panel.tables_empty", &[]),
|
||||||
("panel.tables_title", &[]),
|
("panel.tables_title", &[]),
|
||||||
("status.no_project", &[]),
|
("status.no_project", &[]),
|
||||||
@@ -276,12 +312,44 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("messages.set_verbose", &[]),
|
("messages.set_verbose", &[]),
|
||||||
("messages.show", &["current"]),
|
("messages.show", &["current"]),
|
||||||
("messages.unknown", &["value"]),
|
("messages.unknown", &["value"]),
|
||||||
|
("mode.label_advanced", &[]),
|
||||||
|
("mode.label_advanced_one_shot", &[]),
|
||||||
|
("mode.label_simple", &[]),
|
||||||
("mode.set_advanced", &[]),
|
("mode.set_advanced", &[]),
|
||||||
("mode.set_simple", &[]),
|
("mode.set_simple", &[]),
|
||||||
("mode.show_advanced", &[]),
|
("mode.show_advanced", &[]),
|
||||||
("mode.show_simple", &[]),
|
("mode.show_simple", &[]),
|
||||||
("mode.unknown", &["value"]),
|
("mode.unknown", &["value"]),
|
||||||
("mode.usage", &[]),
|
("mode.usage", &[]),
|
||||||
|
// ---- Cascade-effect summaries (per ADR-0014) ----
|
||||||
|
("db.cascade.action_blocked", &[]),
|
||||||
|
("db.cascade.action_deleted", &[]),
|
||||||
|
("db.cascade.action_set_null", &[]),
|
||||||
|
(
|
||||||
|
"db.cascade.summary",
|
||||||
|
&["count", "action", "child_table", "rel", "on_delete"],
|
||||||
|
),
|
||||||
|
// ---- change-column dry-run diagnostics (per ADR-0017) ----
|
||||||
|
("db.diagnostic.force_conversion_hint", &[]),
|
||||||
|
("db.diagnostic.header_becomes", &[]),
|
||||||
|
("db.diagnostic.header_from", &[]),
|
||||||
|
("db.diagnostic.header_reason", &[]),
|
||||||
|
("db.diagnostic.header_source_rows", &["pk_label"]),
|
||||||
|
("db.diagnostic.header_source_values", &[]),
|
||||||
|
("db.diagnostic.header_to", &[]),
|
||||||
|
("db.diagnostic.header_value", &[]),
|
||||||
|
(
|
||||||
|
"db.diagnostic.incompatible_summary",
|
||||||
|
&["table", "column", "src_ty", "target_ty", "total"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"db.diagnostic.lossy_summary",
|
||||||
|
&["table", "column", "src_ty", "target_ty", "total"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"db.diagnostic.uniqueness_summary",
|
||||||
|
&["table", "column", "src_ty", "target_ty", "total"],
|
||||||
|
),
|
||||||
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
|
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
|
||||||
("ok.rows_deleted", &["count"]),
|
("ok.rows_deleted", &["count"]),
|
||||||
("ok.rows_inserted", &["count"]),
|
("ok.rows_inserted", &["count"]),
|
||||||
|
|||||||
@@ -279,6 +279,19 @@ parse:
|
|||||||
# caret pointer (visualising the failure column) is printed
|
# caret pointer (visualising the failure column) is printed
|
||||||
# on its own preceding line via `parse.caret`.
|
# on its own preceding line via `parse.caret`.
|
||||||
error: "parse error: {detail}"
|
error: "parse error: {detail}"
|
||||||
|
# Custom (try_map / source-slice) error messages raised by
|
||||||
|
# the DSL parser. These were hand-written strings in
|
||||||
|
# `src/dsl/parser.rs` until the catalog migration brought
|
||||||
|
# them under the same roof as the rest of the user-facing
|
||||||
|
# vocabulary. Wording is unchanged from the inline source
|
||||||
|
# form so existing anchor-phrase tests still match.
|
||||||
|
custom:
|
||||||
|
replay_path_expected: "expected a path after `replay`"
|
||||||
|
create_table_needs_pk: |-
|
||||||
|
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.
|
||||||
|
on_action_specified_twice: "`on {target}` specified twice"
|
||||||
|
change_column_flags_exclusive: "`--force-conversion` and `--dont-convert` are mutually exclusive — pick one."
|
||||||
|
unknown_type: "unknown type '{found}' (expected one of: {expected})"
|
||||||
# Caret pointer showing where in the input the parser
|
# Caret pointer showing where in the input the parser
|
||||||
# failed. `{padding}` is the leading whitespace; the
|
# failed. `{padding}` is the leading whitespace; the
|
||||||
# template appends `^` so the rendered line places the
|
# template appends `^` so the rendered line places the
|
||||||
@@ -322,6 +335,22 @@ parse:
|
|||||||
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
|
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
|
||||||
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
|
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
|
||||||
replay: "replay <path> | replay '<path with spaces>'"
|
replay: "replay <path> | replay '<path with spaces>'"
|
||||||
|
# App-lifecycle commands (per ADR-0003, surfaced through
|
||||||
|
# the parser so they participate in usage templates +
|
||||||
|
# completion). Templates here describe the surface
|
||||||
|
# grammar that the parser accepts; the in-app `help`
|
||||||
|
# listing in `help.in_app_body` carries the user-facing
|
||||||
|
# description.
|
||||||
|
quit: "quit | q"
|
||||||
|
help: "help"
|
||||||
|
rebuild: "rebuild"
|
||||||
|
save: "save | save as"
|
||||||
|
new: "new"
|
||||||
|
load: "load"
|
||||||
|
export: "export [<path>]"
|
||||||
|
import: "import <zip-path> [as <target>]"
|
||||||
|
mode: "mode simple | mode advanced"
|
||||||
|
messages: "messages | messages short | messages verbose"
|
||||||
# Single-token vocabulary the renderer uses to translate
|
# Single-token vocabulary the renderer uses to translate
|
||||||
# chumsky's expected-set patterns. One key per Keyword variant
|
# chumsky's expected-set patterns. One key per Keyword variant
|
||||||
# (validated against `Keyword::ALL`), one per Punct variant,
|
# (validated against `Keyword::ALL`), one per Punct variant,
|
||||||
@@ -360,6 +389,23 @@ parse:
|
|||||||
restrict: "`restrict`"
|
restrict: "`restrict`"
|
||||||
action: "`action`"
|
action: "`action`"
|
||||||
"no": "`no`"
|
"no": "`no`"
|
||||||
|
# App-lifecycle commands (per ADR-0003, surfaced through
|
||||||
|
# the parser to drive completion + usage templates).
|
||||||
|
quit: "`quit`"
|
||||||
|
q: "`q`"
|
||||||
|
help: "`help`"
|
||||||
|
rebuild: "`rebuild`"
|
||||||
|
save: "`save`"
|
||||||
|
new: "`new`"
|
||||||
|
load: "`load`"
|
||||||
|
export: "`export`"
|
||||||
|
import: "`import`"
|
||||||
|
mode: "`mode`"
|
||||||
|
messages: "`messages`"
|
||||||
|
simple: "`simple`"
|
||||||
|
advanced: "`advanced`"
|
||||||
|
short: "`short`"
|
||||||
|
verbose: "`verbose`"
|
||||||
punct:
|
punct:
|
||||||
colon: "`:`"
|
colon: "`:`"
|
||||||
open_paren: "`(`"
|
open_paren: "`(`"
|
||||||
@@ -395,6 +441,12 @@ project:
|
|||||||
load_path_missing: "path `{path}` does not exist"
|
load_path_missing: "path `{path}` does not exist"
|
||||||
saveas_target_exists: "`{path}` already exists; pick a different name or remove it first"
|
saveas_target_exists: "`{path}` already exists; pick a different name or remove it first"
|
||||||
import_zip_missing: "zip `{path}` does not exist"
|
import_zip_missing: "zip `{path}` does not exist"
|
||||||
|
# --resume CLI failures printed to stderr before the TUI
|
||||||
|
# starts (ADR-0015 §7). Wording stays one line for clean
|
||||||
|
# piping; the runtime prepends `rdbms-playground: ` from
|
||||||
|
# `cli.binary_prefix` itself.
|
||||||
|
resume_recorded_missing: "--resume: recorded project `{path}` no longer exists"
|
||||||
|
resume_no_previous: "--resume: no previous project recorded under `{data_root}`"
|
||||||
|
|
||||||
# ---- DSL failure wrapper + advanced-mode placeholder + fatal --------
|
# ---- DSL failure wrapper + advanced-mode placeholder + fatal --------
|
||||||
dsl:
|
dsl:
|
||||||
@@ -447,7 +499,11 @@ status:
|
|||||||
panel:
|
panel:
|
||||||
tables_title: "Tables"
|
tables_title: "Tables"
|
||||||
tables_empty: "(none yet)"
|
tables_empty: "(none yet)"
|
||||||
hint_empty: "(no active hint)"
|
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||||
|
# Panel titles for the output and hint panels (rendered inside
|
||||||
|
# the rounded border, hence the leading/trailing space).
|
||||||
|
output_title: "Output"
|
||||||
|
hint_title: "Hint"
|
||||||
|
|
||||||
# ---- Shortcut hints (paired with key names in the bottom bar) -------
|
# ---- Shortcut hints (paired with key names in the bottom bar) -------
|
||||||
shortcut:
|
shortcut:
|
||||||
@@ -473,6 +529,12 @@ mode:
|
|||||||
show_advanced: "mode: advanced"
|
show_advanced: "mode: advanced"
|
||||||
usage: "usage: mode simple | mode advanced"
|
usage: "usage: mode simple | mode advanced"
|
||||||
unknown: "unknown mode '{value}' (expected 'simple' or 'advanced')"
|
unknown: "unknown mode '{value}' (expected 'simple' or 'advanced')"
|
||||||
|
# Labels rendered inside the input panel's border to mark the
|
||||||
|
# current input mode. `label_advanced_one_shot` is shown
|
||||||
|
# while a `:` one-shot is in flight from simple mode.
|
||||||
|
label_simple: "SIMPLE"
|
||||||
|
label_advanced: "ADVANCED"
|
||||||
|
label_advanced_one_shot: "Advanced:"
|
||||||
|
|
||||||
messages:
|
messages:
|
||||||
show: "messages: {current}"
|
show: "messages: {current}"
|
||||||
@@ -480,6 +542,42 @@ messages:
|
|||||||
set_verbose: "messages: verbose"
|
set_verbose: "messages: verbose"
|
||||||
unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')"
|
unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')"
|
||||||
|
|
||||||
|
# ---- Cascade-effect summaries (per ADR-0014 delete reporting) -------
|
||||||
|
db:
|
||||||
|
cascade:
|
||||||
|
# Per-relationship cascade summary appended to a delete
|
||||||
|
# success note. The same template handles cascade,
|
||||||
|
# set-null, and restrict/no-action cases — `{action}` is
|
||||||
|
# one of the three action phrases below.
|
||||||
|
summary: " related: {count} row(s) {action} in `{child_table}` for relationship `{rel}` (on delete {on_delete})"
|
||||||
|
action_deleted: "deleted"
|
||||||
|
action_set_null: "had FK set to null"
|
||||||
|
action_blocked: "blocked"
|
||||||
|
|
||||||
|
# `change column ... (newtype)` dry-run diagnostics (ADR-0017).
|
||||||
|
# Surface when the migration would lose information (lossy),
|
||||||
|
# produce values the target type can't represent
|
||||||
|
# (incompatible), or violate a uniqueness contract (collision).
|
||||||
|
diagnostic:
|
||||||
|
# Column headers for the diagnostic tables.
|
||||||
|
header_from: "From"
|
||||||
|
header_to: "To"
|
||||||
|
header_reason: "Reason"
|
||||||
|
header_value: "Value"
|
||||||
|
header_becomes: "Becomes"
|
||||||
|
header_source_rows: "Source rows ({pk_label})"
|
||||||
|
header_source_values: "Source values"
|
||||||
|
# Summary lines printed above each diagnostic table.
|
||||||
|
lossy_summary: |-
|
||||||
|
Cannot change `{table}.{column}` from {src_ty} to {target_ty}: {total} row(s) would discard information.
|
||||||
|
incompatible_summary: |-
|
||||||
|
Cannot change `{table}.{column}` from {src_ty} to {target_ty}: {total} row(s) cannot be converted.
|
||||||
|
uniqueness_summary: |-
|
||||||
|
Cannot change `{table}.{column}` from {src_ty} to {target_ty}: {total} collision(s) would violate uniqueness.
|
||||||
|
# Follow-up suggestion appended to the lossy diagnostic
|
||||||
|
# (only — incompatibles can't be force-overridden).
|
||||||
|
force_conversion_hint: "if you want to execute this conversion in spite of the problems, re-run with `--force-conversion`."
|
||||||
|
|
||||||
# ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
|
# ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
|
||||||
ok:
|
ok:
|
||||||
# Generic `[ok] <verb> <subject>` header used for every
|
# Generic `[ok] <verb> <subject>` header used for every
|
||||||
|
|||||||
+17
-4
@@ -70,15 +70,21 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
Some(p) if p.exists() => Some(p),
|
Some(p) if p.exists() => Some(p),
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"rdbms-playground: --resume: recorded project `{}` no longer exists",
|
"rdbms-playground: {}",
|
||||||
p.display(),
|
crate::t!(
|
||||||
|
"project.resume_recorded_missing",
|
||||||
|
path = p.display(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"rdbms-playground: --resume: no previous project recorded under `{}`",
|
"rdbms-playground: {}",
|
||||||
data_root.display(),
|
crate::t!(
|
||||||
|
"project.resume_no_previous",
|
||||||
|
data_root = data_root.display(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1609,6 +1615,13 @@ async fn execute_command_typed(
|
|||||||
"Command::Replay is dispatched as Action::Replay; \
|
"Command::Replay is dispatched as Action::Replay; \
|
||||||
reaching execute_command_typed indicates a routing bug"
|
reaching execute_command_typed indicates a routing bug"
|
||||||
),
|
),
|
||||||
|
// App-lifecycle commands are dispatched in App, not by
|
||||||
|
// the database worker. Hitting this arm would mean the
|
||||||
|
// dispatch routing was bypassed.
|
||||||
|
Command::App(_) => unreachable!(
|
||||||
|
"Command::App is dispatched via App::dispatch_app_command; \
|
||||||
|
reaching execute_command_typed indicates a routing bug"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 421
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -23,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││Type a command — press Tab for options, `help` for│
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · mode simple switch · Ctrl-C quit
|
Enter submit · mode simple switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 404
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -23,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││Type a command — press Tab for options, `help` for│
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 412
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -23,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││Type a command — press Tab for options, `help` for│
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 433
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -23,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││: sel │
|
│ ││: sel │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││Type a command — press Tab for options, `help` for│
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 492
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -23,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││Type a command — press Tab for options, `help` for│
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 561
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -23,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││Type a command — press Tab for options, `help` for│
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
|
|||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(theme.border))
|
.border_style(Style::default().fg(theme.border))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" Output ",
|
format!(" {} ", crate::t!("panel.output_title")),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.fg)
|
.fg(theme.fg)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -576,15 +576,23 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
|
|||||||
fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
let effective = app.effective_mode();
|
let effective = app.effective_mode();
|
||||||
let (border_color, mode_color, label) = match effective {
|
let (border_color, mode_color, label) = match effective {
|
||||||
EffectiveMode::Simple => (theme.border, theme.mode_simple, "SIMPLE"),
|
EffectiveMode::Simple => (
|
||||||
EffectiveMode::AdvancedPersistent => {
|
theme.border,
|
||||||
(theme.border_advanced, theme.mode_advanced, "ADVANCED")
|
theme.mode_simple,
|
||||||
}
|
crate::t!("mode.label_simple"),
|
||||||
|
),
|
||||||
|
EffectiveMode::AdvancedPersistent => (
|
||||||
|
theme.border_advanced,
|
||||||
|
theme.mode_advanced,
|
||||||
|
crate::t!("mode.label_advanced"),
|
||||||
|
),
|
||||||
// Mixed-case label distinguishes the one-shot (`:`-triggered)
|
// Mixed-case label distinguishes the one-shot (`:`-triggered)
|
||||||
// state from a persistent advanced mode at a glance.
|
// state from a persistent advanced mode at a glance.
|
||||||
EffectiveMode::AdvancedOneShot => {
|
EffectiveMode::AdvancedOneShot => (
|
||||||
(theme.border_advanced, theme.mode_advanced, "Advanced:")
|
theme.border_advanced,
|
||||||
}
|
theme.mode_advanced,
|
||||||
|
crate::t!("mode.label_advanced_one_shot"),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = Line::from(vec![
|
let title = Line::from(vec![
|
||||||
@@ -681,7 +689,7 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
|||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(theme.border))
|
.border_style(Style::default().fg(theme.border))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" Hint ",
|
format!(" {} ", crate::t!("panel.hint_title")),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.fg)
|
.fg(theme.fg)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -757,6 +765,7 @@ fn render_candidate_line(
|
|||||||
let base_fg = match items[i].kind {
|
let base_fg = match items[i].kind {
|
||||||
crate::completion::CandidateKind::Keyword => theme.tok_keyword,
|
crate::completion::CandidateKind::Keyword => theme.tok_keyword,
|
||||||
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
|
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
|
||||||
|
crate::completion::CandidateKind::Flag => theme.tok_flag,
|
||||||
};
|
};
|
||||||
let mut s = Style::default().fg(base_fg);
|
let mut s = Style::default().fg(base_fg);
|
||||||
if Some(i) == selected {
|
if Some(i) == selected {
|
||||||
|
|||||||
@@ -184,11 +184,20 @@ fn import_with_empty_target_after_as_errors() {
|
|||||||
// making the as-target empty. We surface this as a usage
|
// making the as-target empty. We surface this as a usage
|
||||||
// error rather than silently importing without a target.
|
// error rather than silently importing without a target.
|
||||||
assert!(actions.is_empty());
|
assert!(actions.is_empty());
|
||||||
let last = app.output.back().unwrap();
|
// The friendly parse-error rendering produces multiple
|
||||||
|
// output lines (caret, message, usage). Scan for the anchor
|
||||||
|
// phrase rather than asserting on the final line. The
|
||||||
|
// round-5 refactor moved this error from `handle_import_command`
|
||||||
|
// (single note) into the parser's pre-chumsky path (multi-
|
||||||
|
// line rendering via dispatch_dsl).
|
||||||
|
let anywhere = app
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.text.contains("import") && l.text.contains("target"));
|
||||||
assert!(
|
assert!(
|
||||||
last.text.contains("import") && last.text.contains("target"),
|
anywhere,
|
||||||
"got: {}",
|
"expected 'import' + 'target' somewhere in output: {:?}",
|
||||||
last.text,
|
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user