25800e3eb5
Commands & grammar (step 4):
- AppCommand::Undo/Redo, grammar nodes + REGISTRY entries, catalog
help/usage + keys; parse tests
- replay skips undo/redo (is_app_lifecycle_entry_word) + completion
entry-keyword lockstep; replay-skip test extended
Wiring (step 5):
- Action::{PrepareUndo,PrepareRedo,Undo,Redo} + AppEvent::{UndoPrepared,
UndoUnavailable,UndoSucceeded,UndoFailed}
- App: undo_enabled flag, Modal::UndoConfirm, dispatch + event handling
+ confirm-key handler (Y confirms / N/Esc cancels); "turned off" when
--no-undo; "nothing to undo/redo" when empty
- ui::render_undo_confirm names the command + snapshot time
- runtime: opens with undo enabled (!--no-undo), threads it through the
project-switch path, spawn_prepare_undo/spawn_undo (peek->modal,
restore->refresh tables + schema cache)
- 9 Tier-1 app tests + 3 parse tests
1692 passed / 0 failed / 1 ignored; clippy clean.
290 lines
9.4 KiB
Rust
290 lines
9.4 KiB
Rust
//! App-lifecycle command nodes (ADR-0024 §migration Phase A).
|
|
//!
|
|
//! Eleven commands: quit, help, rebuild, save (+ save as), new,
|
|
//! load, export, import, mode, messages.
|
|
//!
|
|
//! Each block is one `CommandNode`: entry keyword, shape, AST
|
|
//! builder, help / usage references. The ast_builders match
|
|
//! against the `MatchedPath` items in declaration order.
|
|
|
|
use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
|
|
use crate::dsl::grammar::{
|
|
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
|
|
Word,
|
|
};
|
|
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
|
|
|
|
// --- Validators ----------------------------------------------------
|
|
//
|
|
// The catch-all `Ident` branches in `mode <value>` /
|
|
// `messages <value>` exist solely to convert any out-of-set
|
|
// identifier into a friendly `mode.unknown` / `messages.unknown`
|
|
// catalog wording. The known values are `Word` siblings in the
|
|
// same `Choice`, so they're never reached on the happy path —
|
|
// these validators always fail.
|
|
|
|
fn validate_unknown_mode(value: &str) -> Result<(), ValidationError> {
|
|
Err(ValidationError {
|
|
message_key: "mode.unknown",
|
|
args: vec![("value", value.to_string())],
|
|
})
|
|
}
|
|
|
|
fn validate_unknown_messages(value: &str) -> Result<(), ValidationError> {
|
|
Err(ValidationError {
|
|
message_key: "messages.unknown",
|
|
args: vec![("value", value.to_string())],
|
|
})
|
|
}
|
|
|
|
const UNKNOWN_MODE_VALIDATOR: IdentValidator = validate_unknown_mode;
|
|
const UNKNOWN_MESSAGES_VALIDATOR: IdentValidator = validate_unknown_messages;
|
|
|
|
// --- Shapes (constants are referenced by Optional/Choice slices) --
|
|
|
|
const SAVE_AS_WORD: Node = Node::Word(Word::keyword("as"));
|
|
|
|
const IMPORT_TARGET_IDENT: Node = Node::Ident {
|
|
source: IdentSource::NewName,
|
|
role: "target",
|
|
validator: None,
|
|
highlight_override: None,
|
|
writes_table: false,
|
|
writes_column: false,
|
|
writes_user_listed_column: false,
|
|
writes_table_alias: false,
|
|
writes_cte_name: false,
|
|
writes_projection_alias: false,
|
|
};
|
|
const IMPORT_TARGET: Node = Node::Hinted {
|
|
mode: HintMode::ForceProse("hint.ambient_typing_name"),
|
|
inner: &IMPORT_TARGET_IDENT,
|
|
};
|
|
|
|
const IMPORT_AS_TARGET: Node = Node::Seq(&[
|
|
Node::Word(Word::keyword("as")),
|
|
IMPORT_TARGET,
|
|
]);
|
|
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
|
|
|
|
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
|
|
|
|
const EXPORT_PATH_OPT: Node = Node::Optional(&Node::BarePath);
|
|
const IMPORT_BODY_OPT: Node = Node::Optional(&IMPORT_PATH_AND_TARGET);
|
|
|
|
// `mode <value>`: known keywords are surfaced as `Word` children
|
|
// so they appear in the walker's expected set (and feed the
|
|
// completion engine's keyword candidates). The trailing `Ident`
|
|
// child catches any other identifier shape and funnels it into
|
|
// the friendly `mode.unknown` validator.
|
|
const MODE_CHOICES: &[Node] = &[
|
|
Node::Word(Word::keyword("simple")),
|
|
Node::Word(Word::keyword("advanced")),
|
|
Node::Ident {
|
|
source: IdentSource::Free,
|
|
role: "mode_value",
|
|
validator: Some(UNKNOWN_MODE_VALIDATOR),
|
|
highlight_override: None,
|
|
writes_table: false,
|
|
writes_column: false,
|
|
writes_user_listed_column: false,
|
|
writes_table_alias: false,
|
|
writes_cte_name: false,
|
|
writes_projection_alias: false,
|
|
},
|
|
];
|
|
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
|
|
|
|
const MESSAGES_CHOICES: &[Node] = &[
|
|
Node::Word(Word::keyword("short")),
|
|
Node::Word(Word::keyword("verbose")),
|
|
Node::Ident {
|
|
source: IdentSource::Free,
|
|
role: "messages_value",
|
|
validator: Some(UNKNOWN_MESSAGES_VALIDATOR),
|
|
highlight_override: None,
|
|
writes_table: false,
|
|
writes_column: false,
|
|
writes_user_listed_column: false,
|
|
writes_table_alias: false,
|
|
writes_cte_name: false,
|
|
writes_projection_alias: false,
|
|
},
|
|
];
|
|
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
|
|
const MESSAGES_VALUE_OPT: Node = Node::Optional(&MESSAGES_VALUE);
|
|
|
|
const EMPTY_SEQ: Node = Node::Seq(&[]);
|
|
const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
|
|
|
|
// --- AST builders --------------------------------------------------
|
|
|
|
const fn build_quit(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::Quit))
|
|
}
|
|
|
|
const fn build_help(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::Help))
|
|
}
|
|
|
|
const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::Rebuild))
|
|
}
|
|
|
|
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::Undo))
|
|
}
|
|
|
|
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::Redo))
|
|
}
|
|
|
|
fn build_save(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
if path.contains_word("as") {
|
|
Ok(Command::App(AppCommand::SaveAs))
|
|
} else {
|
|
Ok(Command::App(AppCommand::Save))
|
|
}
|
|
}
|
|
|
|
const fn build_new(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::New))
|
|
}
|
|
|
|
const fn build_load(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
Ok(Command::App(AppCommand::Load))
|
|
}
|
|
|
|
fn build_export(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
let bare = path
|
|
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
|
.map(|i| i.text.clone());
|
|
Ok(Command::App(AppCommand::Export { path: bare }))
|
|
}
|
|
|
|
fn build_import(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
let bare_path = path
|
|
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
|
.map(|i| i.text.clone())
|
|
.unwrap_or_default();
|
|
let target = path
|
|
.find(|i| matches!(&i.kind, MatchedKind::Ident { role, .. } if *role == "target"))
|
|
.map(|i| i.text.clone());
|
|
Ok(Command::App(AppCommand::Import {
|
|
path: bare_path,
|
|
target,
|
|
}))
|
|
}
|
|
|
|
fn build_mode(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
// The Choice surfaces the matched value as either a `Word`
|
|
// (known) or an `Ident` (unknown). The unknown branch's
|
|
// validator always errors, so reaching the AST builder
|
|
// implies one of the Word branches matched.
|
|
let value = if path.contains_word("simple") {
|
|
ModeValue::Simple
|
|
} else if path.contains_word("advanced") {
|
|
ModeValue::Advanced
|
|
} else {
|
|
ModeValue::Simple
|
|
};
|
|
Ok(Command::App(AppCommand::Mode { value }))
|
|
}
|
|
|
|
fn build_messages(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
|
let value = if path.contains_word("short") {
|
|
Some(MessagesValue::Short)
|
|
} else if path.contains_word("verbose") {
|
|
Some(MessagesValue::Verbose)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Command::App(AppCommand::Messages { value }))
|
|
}
|
|
|
|
// --- Command nodes -------------------------------------------------
|
|
|
|
pub static QUIT: CommandNode = CommandNode {
|
|
entry: Word::keyword("quit"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_quit,
|
|
help_id: Some("app.quit"),
|
|
usage_ids: &["parse.usage.quit"],};
|
|
|
|
pub static HELP: CommandNode = CommandNode {
|
|
entry: Word::keyword("help"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_help,
|
|
help_id: Some("app.help"),
|
|
usage_ids: &["parse.usage.help"],};
|
|
|
|
pub static REBUILD: CommandNode = CommandNode {
|
|
entry: Word::keyword("rebuild"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_rebuild,
|
|
help_id: Some("app.rebuild"),
|
|
usage_ids: &["parse.usage.rebuild"],};
|
|
|
|
pub static SAVE: CommandNode = CommandNode {
|
|
entry: Word::keyword("save"),
|
|
shape: SAVE_AS_OPT,
|
|
ast_builder: build_save,
|
|
help_id: Some("app.save"),
|
|
usage_ids: &["parse.usage.save"],};
|
|
|
|
pub static NEW: CommandNode = CommandNode {
|
|
entry: Word::keyword("new"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_new,
|
|
help_id: Some("app.new"),
|
|
usage_ids: &["parse.usage.new"],};
|
|
|
|
pub static LOAD: CommandNode = CommandNode {
|
|
entry: Word::keyword("load"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_load,
|
|
help_id: Some("app.load"),
|
|
usage_ids: &["parse.usage.load"],};
|
|
|
|
pub static EXPORT: CommandNode = CommandNode {
|
|
entry: Word::keyword("export"),
|
|
shape: EXPORT_PATH_OPT,
|
|
ast_builder: build_export,
|
|
help_id: Some("app.export"),
|
|
usage_ids: &["parse.usage.export"],};
|
|
|
|
pub static IMPORT: CommandNode = CommandNode {
|
|
entry: Word::keyword("import"),
|
|
shape: IMPORT_BODY_OPT,
|
|
ast_builder: build_import,
|
|
help_id: Some("app.import"),
|
|
usage_ids: &["parse.usage.import"],};
|
|
|
|
pub static MODE: CommandNode = CommandNode {
|
|
entry: Word::keyword("mode"),
|
|
shape: MODE_VALUE,
|
|
ast_builder: build_mode,
|
|
help_id: Some("app.mode"),
|
|
usage_ids: &["parse.usage.mode"],};
|
|
|
|
pub static MESSAGES: CommandNode = CommandNode {
|
|
entry: Word::keyword("messages"),
|
|
shape: MESSAGES_VALUE_OPT,
|
|
ast_builder: build_messages,
|
|
help_id: Some("app.messages"),
|
|
usage_ids: &["parse.usage.messages"],};
|
|
|
|
pub static UNDO: CommandNode = CommandNode {
|
|
entry: Word::keyword("undo"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_undo,
|
|
help_id: Some("app.undo"),
|
|
usage_ids: &["parse.usage.undo"],};
|
|
|
|
pub static REDO: CommandNode = CommandNode {
|
|
entry: Word::keyword("redo"),
|
|
shape: EMPTY_SEQ,
|
|
ast_builder: build_redo,
|
|
help_id: Some("app.redo"),
|
|
usage_ids: &["parse.usage.redo"],};
|