diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -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 diff --git a/.codegraph/config.json b/.codegraph/config.json new file mode 100644 index 0000000..7af60ad --- /dev/null +++ b/.codegraph/config.json @@ -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 +} \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index a062b9e..057bdbf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -889,61 +889,17 @@ impl App { return Vec::new(); } - // Canonical app-level commands recognised in both modes. - // Track-2's full lifecycle command set lands across - // Iterations 4 (rebuild, save, save as, new, load) and - // 5 (export, import). - match effective_input.as_str() { - "quit" | "q" => return vec![Action::Quit], - "help" => { - self.note_help(); - return Vec::new(); - } - "rebuild" => return vec![Action::PrepareRebuild], - "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(); - } - _ => {} + // Parse-first: app-level commands and DSL commands now + // share the chumsky parser (per the round-5 refactor). + // App commands work in both modes — they're not gated by + // `effective_mode`. Anything that parses to a non-App + // variant falls through to the existing mode-specific + // path: simple → DSL execution; advanced → SQL placeholder. + // Anything that fails to parse falls through too — the + // simple-mode path renders the friendly parse error, the + // advanced-mode path renders the SQL placeholder. + if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) { + return self.dispatch_app_command(app_cmd, &effective_input); } // 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 { + 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 { match parse_command(input) { Ok(Command::Replay { path }) => { @@ -1268,6 +1297,13 @@ impl App { (Operation::Query, Some(name.as_str()), 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 { @@ -1298,42 +1334,6 @@ impl App { /// "as" is fine — the separator only matches when /// surrounded by spaces. `split_once` is used (first /// occurrence wins), which is the natural reading. - fn handle_import_command(&mut self, rest: &str) -> Vec { - 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. /// /// `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 { use crate::dsl::ReferentialAction; - let what = match effect.action { - ReferentialAction::Cascade => "deleted", - ReferentialAction::SetNull => "had FK set to null", - ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked", + let action_key = match effect.action { + ReferentialAction::Cascade => "db.cascade.action_deleted", + ReferentialAction::SetNull => "db.cascade.action_set_null", + ReferentialAction::Restrict | ReferentialAction::NoAction => { + "db.cascade.action_blocked" + } }; - format!( - " related: {} row(s) {} in `{}` for relationship `{}` (on delete {})", - effect.rows_changed, - what, - effect.child_table, - effect.relationship_name, - effect.action, + crate::t!( + "db.cascade.summary", + count = effect.rows_changed, + action = crate::friendly::translate(action_key, &[]), + child_table = effect.child_table, + rel = effect.relationship_name, + on_delete = effect.action, ) } @@ -1966,13 +1968,17 @@ mod tests { // Stage-8 follow-up #2 (testing-round-2): the // single-candidate-no-memo design lets the user chain // 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(); - type_str(&mut app, "a"); + type_str(&mut app, "cr"); app.update(key(KeyCode::Tab)); - assert_eq!(app.input, "add "); + assert_eq!(app.input, "create "); app.update(key(KeyCode::Tab)); - assert_eq!(app.input, "add column "); + assert_eq!(app.input, "create table "); assert!(app.last_completion.is_none()); } @@ -2102,9 +2108,24 @@ mod tests { type_str(&mut app, "mode sideways"); submit(&mut app); assert_eq!(app.mode, Mode::Simple); - let last = app.output.back().unwrap(); - assert_eq!(last.kind, OutputKind::Error); - assert!(last.text.contains("unknown mode")); + // The error surfaces somewhere in the output buffer + // (could be the caret line, the parse-error detail + // 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::>(), + ); + let any_error = app + .output + .iter() + .any(|l| l.kind == OutputKind::Error); + assert!(any_error, "expected at least one Error line"); } #[test] diff --git a/src/completion.rs b/src/completion.rs index 29a4605..70c5f91 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -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 = 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 = 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 = 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 = - 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 = 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::>(); + 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); diff --git a/src/db.rs b/src/db.rs index 4927381..a57a5e4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2632,7 +2632,11 @@ fn render_lossy_diagnostic( lossies: &[&Outcome], ) -> String { 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); alignments.extend([ @@ -2662,18 +2666,22 @@ fn render_lossy_diagnostic( } let mut out = format!( - "Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \ - {total} row(s) would discard information.\n\n" + "{}\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) { out.push_str(&line); out.push('\n'); } out.push('\n'); - out.push_str( - "if you want to execute this conversion in spite of the problems, \ - re-run with `--force-conversion`.", - ); + out.push_str(&crate::t!("db.diagnostic.force_conversion_hint")); out } @@ -2688,7 +2696,10 @@ fn render_incompatible_diagnostic( incompatibles: &[&Outcome], ) -> String { 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); alignments.extend([ @@ -2714,8 +2725,15 @@ fn render_incompatible_diagnostic( } let mut out = format!( - "Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \ - {total} row(s) cannot be converted.\n\n" + "{}\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) { out.push_str(&line); @@ -2766,9 +2784,9 @@ fn check_uniqueness_collisions( let pk_label = pk_columns.join(", "); let headers = vec![ - "Becomes".to_string(), - format!("Source rows ({pk_label})"), - "Source values".to_string(), + crate::t!("db.diagnostic.header_becomes"), + crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label), + crate::t!("db.diagnostic.header_source_values"), ]; let alignments = vec![ @@ -2814,8 +2832,15 @@ fn check_uniqueness_collisions( let _ = old_schema; let mut out = format!( - "Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \ - {total} collision(s) would violate uniqueness.\n\n" + "{}\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) { out.push_str(&line); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 8de9dae..e3a7217 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -142,6 +142,60 @@ pub enum Command { Replay { 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 }, + /// 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 }, + /// Switch the persistent input mode. + Mode { value: ModeValue }, + /// Show or set the messages verbosity. + Messages { value: Option }, +} + +#[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). @@ -218,6 +272,19 @@ impl Command { Self::Delete { .. } => "delete from", Self::ShowData { .. } => "show data", 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 // the most identifying thing for log output. 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(_) => "", } } diff --git a/src/dsl/keyword.rs b/src/dsl/keyword.rs index 4132069..a5c77e5 100644 --- a/src/dsl/keyword.rs +++ b/src/dsl/keyword.rs @@ -105,6 +105,31 @@ define_keywords! { Restrict => "restrict", Action => "action", 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 ` and `messages `. + // 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 { diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index 9936c5b..c84801b 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -22,7 +22,8 @@ pub mod value; pub use action::ReferentialAction; pub use command::{ - ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter, + AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue, + RelationshipSelector, RowFilter, }; pub use parser::{ParseError, parse_command}; pub use types::Type; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index b7d4bf3..94afdee 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -15,7 +15,8 @@ use chumsky::prelude::*; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ - ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter, + AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue, + RelationshipSelector, RowFilter, }; use crate::dsl::ident_slot::IdentSlot; use crate::dsl::keyword::{Keyword, Punct}; @@ -101,6 +102,9 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result Ok(cmd), Err(errs) => Err(into_parse_error(&errs, tokens, source)), @@ -139,7 +143,7 @@ fn try_parse_replay_with_bare_path( // of error chumsky would (positioned where the path // should have started). return Some(Err(ParseError::Invalid { - message: "expected a path after `replay`".to_string(), + message: crate::t!("parse.custom.replay_path_expected"), position: after_replay, at_eof: true, expected: vec!["path".to_string()], @@ -150,6 +154,65 @@ fn try_parse_replay_with_bare_path( })) } +/// `export ` / `import [as ]` 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> { + use crate::dsl::command::AppCommand; + let first = tokens.first()?; + let kw = match &first.kind { + TokenKind::Keyword(Keyword::Export) => Keyword::Export, + TokenKind::Keyword(Keyword::Import) => Keyword::Import, + _ => return None, + }; + let after = first.span.1; + let rest = source[after..].trim(); + if rest.is_empty() { + return None; + } + match kw { + Keyword::Export => Some(Ok(Command::App(AppCommand::Export { + path: Some(rest.to_string()), + }))), + Keyword::Import => { + // Trailing `as` with no target is a recognised user + // mistake — surface the usage hint as a parse error + // (catalog wording stays in sync with the existing + // dispatch-time error). + if rest == "as" || rest.ends_with(" as") { + return Some(Err(ParseError::Invalid { + message: crate::t!("project.import_empty_target"), + position: after + rest.len(), + at_eof: true, + expected: Vec::new(), + })); + } + let (path, target) = match rest.split_once(" as ") { + Some((p, t)) => (p.trim().to_string(), Some(t.trim().to_string())), + None => (rest.to_string(), None), + }; + if path.is_empty() { + return Some(Err(ParseError::Invalid { + message: crate::t!("project.import_usage"), + position: after, + at_eof: true, + expected: vec!["path".to_string()], + })); + } + Some(Ok(Command::App(AppCommand::Import { path, target }))) + } + _ => None, + } +} + // ========================================================= // Token-aware combinator helpers (ADR-0020 §5) // ========================================================= @@ -287,10 +350,7 @@ fn command_parser<'a>() if pk_specs.is_empty() { return Err(Rich::custom( span, - "tables need at least one column. Add `with pk` for a default \ - `id INTEGER PRIMARY KEY`, or `with pk :` to choose. \ - Use a comma-separated list for compound primary keys." - .to_string(), + crate::t!("parse.custom.create_table_needs_pk"), )); } let columns: Vec = pk_specs @@ -390,6 +450,66 @@ fn command_parser<'a>() .ignore_then(string_payload()) .map(|path| Command::Replay { path }); + // ---- App-lifecycle commands ----------------------------- + // No-arg variants and the keyword-value variants. Path- + // bearing variants (`export `, `import [as + // ]`) 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 ` and `messages []` 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::(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::(Rich::custom( + span, + crate::t!("messages.unknown", value = s), + )) + }); + let messages_cmd = kw(Keyword::Messages) + .ignore_then(choice((known_messages, unknown_messages)).or_not()) + .map(|value| Command::App(AppCommand::Messages { value })); + choice(( create_table, // `drop column` and `drop relationship` come before @@ -408,6 +528,19 @@ fn command_parser<'a>() update_cmd, delete_cmd, replay, + // App commands. `save as` before bare `save`; everything + // else order-agnostic. + quit_cmd, + help_cmd, + rebuild_cmd, + save_as_cmd, + save_cmd, + new_cmd, + load_cmd, + export_no_arg, + import_no_arg, + mode_cmd, + messages_cmd, )) .then_ignore(end()) } @@ -505,9 +638,15 @@ fn filter_clause<'a>() let all_rows = flag("all-rows").to(RowFilter::AllRows); - where_clause - .or(all_rows) - .labelled("where clause or --all-rows") + // No `.labelled()` wrap here: chumsky's expected-set then + // surfaces the constituent options (`` `where` ``, + // `` `--all-rows` ``) individually instead of collapsing + // them to a single descriptive label. The completion + // engine needs the constituents to offer Tab candidates + // (ADR-0022 §8); the resulting error prose ("expected `,`, + // `where`, or `--all-rows`") reads cleanly enough without + // hand-wrapping. + where_clause.or(all_rows) } fn value_literal<'a>() @@ -625,7 +764,10 @@ fn referential_clauses<'a>() -> impl Parser< if slot.is_some() { return Err(Rich::custom( span, - format!("`on {target}` specified twice"), + crate::t!( + "parse.custom.on_action_specified_twice", + target = target, + ), )); } *slot = Some(action); @@ -683,9 +825,7 @@ fn change_column_flags<'a>() [single] => Ok(*single), _ => Err(Rich::custom( span, - "`--force-conversion` and `--dont-convert` are mutually \ - exclusive — pick one." - .to_string(), + crate::t!("parse.custom.change_column_flags_exclusive"), )), }) } diff --git a/src/dsl/types.rs b/src/dsl/types.rs index 476a0d9..0967205 100644 --- a/src/dsl/types.rs +++ b/src/dsl/types.rs @@ -130,15 +130,29 @@ impl fmt::Display for Type { } } -/// Error returned when parsing a type keyword that isn't -/// recognised. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("unknown type '{found}' (expected one of: {expected})")] +/// Error returned when parsing a type keyword that isn't recognised. +/// +/// Display formatting flows through the i18n catalog +/// (`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 found: 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 { type Err = UnknownType; diff --git a/src/dsl/usage.rs b/src/dsl/usage.rs index 409aaf1..e4aeacc 100644 --- a/src/dsl/usage.rs +++ b/src/dsl/usage.rs @@ -92,6 +92,53 @@ pub const REGISTRY: &[UsageEntry] = &[ entry: Keyword::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. @@ -155,11 +202,12 @@ mod tests { #[test] fn every_command_has_a_registry_entry() { - // The parser recognises ten command-entry keywords - // (ADR-0009 + ADR-0006 + ADR-0014). Each MUST be - // represented in the registry — otherwise a parse error - // for that command renders no usage block and the H1a - // pedagogy gap reopens for that family. + // Every command-entry keyword recognised by the parser + // MUST be represented in the registry — otherwise a + // parse error for that command renders no usage block + // and the H1a pedagogy gap reopens for that family. + // Round 5 added the app-lifecycle entry keywords + // alongside the original ten DSL entry keywords. for entry in [ Keyword::Create, Keyword::Drop, @@ -171,6 +219,17 @@ mod tests { Keyword::Update, Keyword::Delete, 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!( REGISTRY.iter().any(|e| e.entry == entry), @@ -246,14 +305,19 @@ mod tests { } #[test] - fn entry_keywords_alphabetised_returns_ten_unique_sorted_commands() { + fn entry_keywords_alphabetised_returns_unique_sorted_commands() { let keys = entry_keywords_alphabetised(); 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!( names, vec![ - "add", "change", "create", "delete", "drop", "insert", - "rename", "replay", "show", "update", + "add", "change", "create", "delete", "drop", "export", + "help", "import", "insert", "load", "messages", "mode", + "new", "q", "quit", "rebuild", "rename", "replay", + "save", "show", "update", ], ); } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index c33a633..49188fe 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -140,6 +140,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // ---- Parse error rendering ---- ("parse.available_commands", &["commands"]), ("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.error", &["detail"]), // 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.insert", &[]), ("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.save", &[]), ("parse.usage.show_data", &[]), ("parse.usage.show_table", &[]), ("parse.usage.update", &[]), @@ -176,6 +193,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.token.identifier", &[]), ("parse.token.keyword.action", &[]), ("parse.token.keyword.add", &[]), + ("parse.token.keyword.advanced", &[]), ("parse.token.keyword.as", &[]), ("parse.token.keyword.cascade", &[]), ("parse.token.keyword.change", &[]), @@ -184,26 +202,40 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.token.keyword.data", &[]), ("parse.token.keyword.delete", &[]), ("parse.token.keyword.drop", &[]), + ("parse.token.keyword.export", &[]), ("parse.token.keyword.false", &[]), ("parse.token.keyword.from", &[]), + ("parse.token.keyword.help", &[]), + ("parse.token.keyword.import", &[]), ("parse.token.keyword.in", &[]), ("parse.token.keyword.insert", &[]), ("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.null", &[]), ("parse.token.keyword.on", &[]), ("parse.token.keyword.pk", &[]), + ("parse.token.keyword.q", &[]), + ("parse.token.keyword.quit", &[]), + ("parse.token.keyword.rebuild", &[]), ("parse.token.keyword.relationship", &[]), ("parse.token.keyword.rename", &[]), ("parse.token.keyword.replay", &[]), ("parse.token.keyword.restrict", &[]), + ("parse.token.keyword.save", &[]), ("parse.token.keyword.set", &[]), + ("parse.token.keyword.short", &[]), ("parse.token.keyword.show", &[]), + ("parse.token.keyword.simple", &[]), ("parse.token.keyword.table", &[]), ("parse.token.keyword.to", &[]), ("parse.token.keyword.true", &[]), ("parse.token.keyword.update", &[]), ("parse.token.keyword.values", &[]), + ("parse.token.keyword.verbose", &[]), ("parse.token.keyword.where", &[]), ("parse.token.keyword.with", &[]), ("parse.token.number", &[]), @@ -222,6 +254,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("project.import_usage", &[]), ("project.import_zip_missing", &["path"]), ("project.load_path_missing", &["path"]), + ("project.resume_no_previous", &["data_root"]), + ("project.resume_recorded_missing", &["path"]), ("project.saveas_target_exists", &["path"]), ("project.rebuild_failed", &["error"]), ("project.rebuild_ok", &["summary"]), @@ -248,6 +282,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("modal.rebuild_confirm_title", &[]), // ---- Status bar + panels ---- ("panel.hint_empty", &[]), + ("panel.hint_title", &[]), + ("panel.output_title", &[]), ("panel.tables_empty", &[]), ("panel.tables_title", &[]), ("status.no_project", &[]), @@ -276,12 +312,44 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("messages.set_verbose", &[]), ("messages.show", &["current"]), ("messages.unknown", &["value"]), + ("mode.label_advanced", &[]), + ("mode.label_advanced_one_shot", &[]), + ("mode.label_simple", &[]), ("mode.set_advanced", &[]), ("mode.set_simple", &[]), ("mode.show_advanced", &[]), ("mode.show_simple", &[]), ("mode.unknown", &["value"]), ("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) ---- ("ok.rows_deleted", &["count"]), ("ok.rows_inserted", &["count"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 72ebec2..0ed6068 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -279,6 +279,19 @@ parse: # caret pointer (visualising the failure column) is printed # on its own preceding line via `parse.caret`. 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 :` 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 # failed. `{padding}` is the leading whitespace; the # template appends `^` so the rendered line places the @@ -322,6 +335,22 @@ parse: update: "update set =[, ...] (where = | --all-rows)" delete: "delete from
(where = | --all-rows)" replay: "replay | replay ''" + # 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 []" + import: "import [as ]" + mode: "mode simple | mode advanced" + messages: "messages | messages short | messages verbose" # Single-token vocabulary the renderer uses to translate # chumsky's expected-set patterns. One key per Keyword variant # (validated against `Keyword::ALL`), one per Punct variant, @@ -360,6 +389,23 @@ parse: restrict: "`restrict`" action: "`action`" "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: colon: "`:`" open_paren: "`(`" @@ -395,6 +441,12 @@ project: load_path_missing: "path `{path}` does not exist" saveas_target_exists: "`{path}` already exists; pick a different name or remove it first" 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: @@ -447,7 +499,11 @@ status: panel: tables_title: "Tables" 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: @@ -473,6 +529,12 @@ mode: show_advanced: "mode: advanced" usage: "usage: mode simple | mode 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: show: "messages: {current}" @@ -480,6 +542,42 @@ messages: set_verbose: "messages: 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) -------------- ok: # Generic `[ok] ` header used for every diff --git a/src/runtime.rs b/src/runtime.rs index 7e0bc85..93dddac 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -70,15 +70,21 @@ pub async fn run(args: Args) -> Result<()> { Some(p) if p.exists() => Some(p), Some(p) => { eprintln!( - "rdbms-playground: --resume: recorded project `{}` no longer exists", - p.display(), + "rdbms-playground: {}", + crate::t!( + "project.resume_recorded_missing", + path = p.display(), + ), ); return Ok(()); } None => { eprintln!( - "rdbms-playground: --resume: no previous project recorded under `{}`", - data_root.display(), + "rdbms-playground: {}", + crate::t!( + "project.resume_no_previous", + data_root = data_root.display(), + ), ); return Ok(()); } @@ -1609,6 +1615,13 @@ async fn execute_command_typed( "Command::Replay is dispatched as Action::Replay; \ 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" + ), } } diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap index 303f167..5b9c31b 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 421 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -23,7 +22,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││Type a command — press Tab for options, `help` for│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · mode simple switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index be0d7af..3ccb6d0 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 404 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -23,7 +22,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││Type a command — press Tab for options, `help` for│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index 433560d..3ccb6d0 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 412 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -23,7 +22,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││Type a command — press Tab for options, `help` for│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index d2daacd..0f94f63 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 433 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -23,7 +22,7 @@ expression: snapshot │ ││: sel │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││Type a command — press Tab for options, `help` for│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · Backspace cancel one-shot · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index ce28705..46e7e01 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 492 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -23,7 +22,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││Type a command — press Tab for options, `help` for│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap index 8dcd22d..082c09e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 561 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -23,7 +22,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││Type a command — press Tab for options, `help` for│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 48d3f57..e53440f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -444,7 +444,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( - " Output ", + format!(" {} ", crate::t!("panel.output_title")), Style::default() .fg(theme.fg) .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) { let effective = app.effective_mode(); let (border_color, mode_color, label) = match effective { - EffectiveMode::Simple => (theme.border, theme.mode_simple, "SIMPLE"), - EffectiveMode::AdvancedPersistent => { - (theme.border_advanced, theme.mode_advanced, "ADVANCED") - } + EffectiveMode::Simple => ( + theme.border, + 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) // state from a persistent advanced mode at a glance. - EffectiveMode::AdvancedOneShot => { - (theme.border_advanced, theme.mode_advanced, "Advanced:") - } + EffectiveMode::AdvancedOneShot => ( + theme.border_advanced, + theme.mode_advanced, + crate::t!("mode.label_advanced_one_shot"), + ), }; 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_style(Style::default().fg(theme.border)) .title(Span::styled( - " Hint ", + format!(" {} ", crate::t!("panel.hint_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), @@ -757,6 +765,7 @@ fn render_candidate_line( let base_fg = match items[i].kind { crate::completion::CandidateKind::Keyword => theme.tok_keyword, crate::completion::CandidateKind::Identifier => theme.tok_identifier, + crate::completion::CandidateKind::Flag => theme.tok_flag, }; let mut s = Style::default().fg(base_fg); if Some(i) == selected { diff --git a/tests/iteration5_export_import.rs b/tests/iteration5_export_import.rs index 5fbb012..c0dba75 100644 --- a/tests/iteration5_export_import.rs +++ b/tests/iteration5_export_import.rs @@ -184,11 +184,20 @@ fn import_with_empty_target_after_as_errors() { // making the as-target empty. We surface this as a usage // error rather than silently importing without a target. 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!( - last.text.contains("import") && last.text.contains("target"), - "got: {}", - last.text, + anywhere, + "expected 'import' + 'target' somewhere in output: {:?}", + app.output.iter().map(|l| &l.text).collect::>(), ); }