use escriba_command::{Command, CommandRegistry};
use escriba_core::{Action, Mode, Motion};
use escriba_keymap::{Key, Keymap};
use crate::{ApplyPlan, KeybindSpec, LispError, LispResult};
#[derive(Debug, Clone, Default)]
pub struct GrammarApplyReport {
pub extensions_registered: u32,
pub extensions_skipped_unknown_language: u32,
pub unknown_languages: Vec<String>,
}
pub fn apply_plan_to_grammar_extensions<F, G>(
plan: &ApplyPlan,
mut is_known_grammar: F,
mut add_extension: G,
) -> GrammarApplyReport
where
F: FnMut(&str) -> bool,
G: FnMut(&str, &str),
{
let mut report = GrammarApplyReport::default();
for mode in &plan.major_modes {
if mode.tree_sitter.is_empty() {
continue;
}
if !is_known_grammar(&mode.tree_sitter) {
report.extensions_skipped_unknown_language += mode.extensions.len() as u32;
if !report
.unknown_languages
.iter()
.any(|l| l == &mode.tree_sitter)
{
report.unknown_languages.push(mode.tree_sitter.clone());
}
continue;
}
for ext in &mode.extensions {
add_extension(&mode.tree_sitter, ext);
report.extensions_registered += 1;
}
}
report
}
#[derive(Debug, Clone, Default)]
pub struct ApplyReport {
pub keybinds_applied: u32,
pub keybinds_deferred_to_commands: u32,
pub keybinds_sequences: u32,
pub warnings: Vec<String>,
}
impl ApplyReport {
#[must_use]
pub fn summary(&self) -> String {
format!(
"keybinds={} deferred_cmd={} sequences={} warnings={}",
self.keybinds_applied,
self.keybinds_deferred_to_commands,
self.keybinds_sequences,
self.warnings.len(),
)
}
}
pub fn apply_plan_to_keymap(plan: &ApplyPlan, keymap: &mut Keymap) -> ApplyReport {
let mut report = ApplyReport::default();
for spec in &plan.keybinds {
match apply_keybind(spec, keymap) {
Ok(applied) => {
report.keybinds_applied += 1;
if applied.deferred_to_command {
report.keybinds_deferred_to_commands += 1;
}
if applied.is_sequence {
report.keybinds_sequences += 1;
}
}
Err(warning) => {
report.warnings.push(warning);
}
}
}
report
}
struct KeybindApplied {
is_sequence: bool,
deferred_to_command: bool,
}
fn apply_keybind(spec: &KeybindSpec, keymap: &mut Keymap) -> Result<KeybindApplied, String> {
let mode =
parse_mode(&spec.mode).map_err(|e| format!("defkeybind :mode {:?} — {e}", spec.mode))?;
let leader = keymap.leader().clone();
let keys = parse_key_sequence(&spec.key, &leader)
.map_err(|e| format!("defkeybind :key {:?} — {e}", spec.key))?;
let (action, deferred) = resolve_action(&spec.action);
let description = if spec.description.is_empty() {
spec.action.clone()
} else {
spec.description.clone()
};
let is_sequence = keys.len() > 1;
keymap.bind_sequence(mode, keys, action, description);
Ok(KeybindApplied {
is_sequence,
deferred_to_command: deferred,
})
}
fn parse_key_sequence(s: &str, leader: &Key) -> Result<Vec<Key>, String> {
let mut keys = Vec::new();
let mut chars = s.chars().peekable();
while let Some(&c) = chars.peek() {
let token: String = if c == '<' {
let mut t = String::new();
for ch in chars.by_ref() {
t.push(ch);
if ch == '>' {
break;
}
}
if !t.ends_with('>') {
return Err(format!("unterminated `<` in key `{s}`"));
}
t
} else {
chars.next();
c.to_string()
};
let key = parse_key_token(&token, leader)
.ok_or_else(|| format!("unknown key token `{token}` in `{s}`"))?;
keys.push(key);
}
if keys.is_empty() {
return Err(format!("empty key string `{s}`"));
}
Ok(keys)
}
fn parse_key_token(tok: &str, leader: &Key) -> Option<Key> {
if let Some(inner) = tok.strip_prefix('<').and_then(|r| r.strip_suffix('>')) {
let lower = inner.to_ascii_lowercase();
if lower == "leader" || lower == "localleader" {
return Some(leader.clone());
}
return parse_bracket_key(inner);
}
let mut chars = tok.chars();
let c = chars.next()?;
if chars.next().is_none() {
Some(Key::Char(c))
} else {
None
}
}
#[must_use]
pub fn parse_leader_key(value: &str) -> Option<Key> {
let trimmed = value.trim();
let lower = trimmed.to_ascii_lowercase();
if lower == "<leader>" || lower == "<localleader>" {
return None;
}
parse_key_token(trimmed, &Key::Char(','))
}
fn parse_mode(name: &str) -> LispResult<Mode> {
match name {
"normal" => Ok(Mode::Normal),
"insert" => Ok(Mode::Insert),
"visual" => Ok(Mode::Visual),
"visual-line" => Ok(Mode::VisualLine),
"command" => Ok(Mode::Command),
_ => Err(LispError::UnknownMode(name.to_string())),
}
}
fn parse_bracket_key(inner: &str) -> Option<Key> {
let lower = inner.to_ascii_lowercase();
let named: &[(&str, Key)] = &[
("esc", Key::Esc),
("escape", Key::Esc),
("cr", Key::Enter),
("enter", Key::Enter),
("return", Key::Enter),
("tab", Key::Tab),
("bs", Key::Backspace),
("backspace", Key::Backspace),
("left", Key::Left),
("right", Key::Right),
("up", Key::Up),
("down", Key::Down),
("home", Key::Home),
("end", Key::End),
("pageup", Key::PageUp),
("pagedown", Key::PageDown),
("space", Key::Char(' ')),
("spc", Key::Char(' ')),
];
for (n, k) in named {
if lower == *n {
return Some(k.clone());
}
}
if let Some(rest) = inner.strip_prefix("C-").or_else(|| inner.strip_prefix("c-")) {
return single_char(rest).map(Key::Ctrl);
}
if let Some(rest) = inner
.strip_prefix("A-")
.or_else(|| inner.strip_prefix("a-"))
.or_else(|| inner.strip_prefix("M-"))
.or_else(|| inner.strip_prefix("m-"))
{
return single_char(rest).map(Key::Alt);
}
None
}
fn single_char(s: &str) -> Option<char> {
let mut chars = s.chars();
let c = chars.next()?;
if chars.next().is_none() { Some(c) } else { None }
}
fn resolve_action(name: &str) -> (Action, bool) {
match name {
"insert" | "enter-insert" => (Action::ChangeMode(Mode::Insert), false),
"normal" | "enter-normal" => (Action::ChangeMode(Mode::Normal), false),
"visual" | "enter-visual" => (Action::ChangeMode(Mode::Visual), false),
"visual-line" | "enter-visual-line" => (Action::ChangeMode(Mode::VisualLine), false),
"command" | "enter-command" => (Action::ChangeMode(Mode::Command), false),
"move-left" => (Action::Move(Motion::Left), false),
"move-right" => (Action::Move(Motion::Right), false),
"move-up" => (Action::Move(Motion::Up), false),
"move-down" => (Action::Move(Motion::Down), false),
"word-next-start" => (Action::Move(Motion::WordStartNext), false),
"word-next-end" => (Action::Move(Motion::WordEndNext), false),
"word-prev-start" => (Action::Move(Motion::WordStartPrev), false),
"line-start" => (Action::Move(Motion::LineStart), false),
"line-first-non-blank" => (Action::Move(Motion::LineFirstNonBlank), false),
"line-end" => (Action::Move(Motion::LineEnd), false),
"doc-start" => (Action::Move(Motion::DocStart), false),
"doc-end" => (Action::Move(Motion::DocEnd), false),
"page-up" => (Action::Move(Motion::PageUp), false),
"page-down" => (Action::Move(Motion::PageDown), false),
"half-page-up" => (Action::Move(Motion::HalfPageUp), false),
"half-page-down" => (Action::Move(Motion::HalfPageDown), false),
"forward-sexp" => (Action::Move(Motion::ForwardSexp), false),
"backward-sexp" => (Action::Move(Motion::BackwardSexp), false),
"up-list" => (Action::Move(Motion::UpList), false),
"down-list" => (Action::Move(Motion::DownList), false),
"beginning-of-defun" => (Action::Move(Motion::BeginningOfDefun), false),
"end-of-defun" => (Action::Move(Motion::EndOfDefun), false),
"beginning-of-sexp" => (Action::Move(Motion::BeginningOfSexp), false),
"end-of-sexp" => (Action::Move(Motion::EndOfSexp), false),
"undo" => (Action::Undo, false),
"redo" => (Action::Redo, false),
"save" => (Action::Save, false),
"quit" => (Action::Quit, false),
"submit-command" => (Action::SubmitCommand, false),
other => (
Action::Command {
name: other.to_string(),
args: Vec::new(),
},
true,
),
}
}
#[derive(Debug, Clone, Default)]
pub struct CommandApplyReport {
pub registered: u32,
pub overridden: u32,
pub overridden_names: Vec<String>,
}
impl CommandApplyReport {
#[must_use]
pub fn summary(&self) -> String {
format!(
"commands_registered={} overridden={}",
self.registered, self.overridden,
)
}
}
pub fn apply_plan_to_commands(
plan: &ApplyPlan,
registry: &mut CommandRegistry,
) -> CommandApplyReport {
let mut report = CommandApplyReport::default();
for spec in &plan.commands {
if registry.contains(&spec.name) {
report.overridden += 1;
report.overridden_names.push(spec.name.clone());
} else {
report.registered += 1;
}
registry.register(Command::action(
spec.name.clone(),
spec.description.clone(),
spec.action.clone(),
));
}
report
}
#[derive(Debug, Clone, Default)]
pub struct OptionApplyReport {
pub set: u32,
pub overridden: u32,
}
impl OptionApplyReport {
#[must_use]
pub fn summary(&self) -> String {
format!("options_set={} overridden={}", self.set, self.overridden)
}
}
pub fn apply_plan_to_options(
plan: &ApplyPlan,
options: &mut std::collections::HashMap<String, String>,
) -> OptionApplyReport {
let mut report = OptionApplyReport::default();
for opt in &plan.options {
if options.insert(opt.name.clone(), opt.value.clone()).is_some() {
report.overridden += 1;
} else {
report.set += 1;
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
use crate::apply_source;
#[test]
fn parse_single_char_token() {
let l = Key::Char(',');
assert_eq!(parse_key_token("a", &l), Some(Key::Char('a')));
assert_eq!(parse_key_token("G", &l), Some(Key::Char('G')));
assert_eq!(parse_key_token("0", &l), Some(Key::Char('0')));
}
#[test]
fn parse_named_bracket_tokens() {
let l = Key::Char(',');
assert_eq!(parse_key_token("<Esc>", &l), Some(Key::Esc));
assert_eq!(parse_key_token("<CR>", &l), Some(Key::Enter));
assert_eq!(parse_key_token("<Enter>", &l), Some(Key::Enter));
assert_eq!(parse_key_token("<Tab>", &l), Some(Key::Tab));
assert_eq!(parse_key_token("<Backspace>", &l), Some(Key::Backspace));
assert_eq!(parse_key_token("<BS>", &l), Some(Key::Backspace));
assert_eq!(parse_key_token("<Left>", &l), Some(Key::Left));
assert_eq!(parse_key_token("<PageUp>", &l), Some(Key::PageUp));
}
#[test]
fn parse_modifier_bracket_tokens() {
let l = Key::Char(',');
assert_eq!(parse_key_token("<C-r>", &l), Some(Key::Ctrl('r')));
assert_eq!(parse_key_token("<c-r>", &l), Some(Key::Ctrl('r')));
assert_eq!(parse_key_token("<A-f>", &l), Some(Key::Alt('f')));
assert_eq!(parse_key_token("<M-f>", &l), Some(Key::Alt('f')));
}
#[test]
fn parse_leader_token_resolves_to_leader() {
assert_eq!(
parse_key_token("<leader>", &Key::Char(',')),
Some(Key::Char(','))
);
assert_eq!(
parse_key_token("<Leader>", &Key::Char(' ')),
Some(Key::Char(' '))
);
assert_eq!(
parse_key_token("<localleader>", &Key::Char('\\')),
Some(Key::Char('\\'))
);
}
#[test]
fn parse_token_rejects_multichar_and_unknown_bracket() {
let l = Key::Char(',');
assert_eq!(parse_key_token("gh", &l), None);
assert_eq!(parse_key_token("<Galactus>", &l), None);
}
#[test]
fn parse_key_sequence_tokenizes_leader_and_brackets() {
let comma = Key::Char(',');
assert_eq!(
parse_key_sequence("<leader>ff", &comma).unwrap(),
vec![Key::Char(','), Key::Char('f'), Key::Char('f')],
);
assert_eq!(
parse_key_sequence("gg", &comma).unwrap(),
vec![Key::Char('g'), Key::Char('g')],
);
assert_eq!(
parse_key_sequence("<C-w>h", &comma).unwrap(),
vec![Key::Ctrl('w'), Key::Char('h')],
);
assert_eq!(parse_key_sequence("x", &comma).unwrap(), vec![Key::Char('x')]);
assert!(parse_key_sequence("<leader", &comma).is_err());
assert!(parse_key_sequence("<Nope>", &comma).is_err());
}
#[test]
fn space_token_parses_in_tokens_and_sequences() {
let comma = Key::Char(',');
assert_eq!(parse_key_token("<space>", &comma), Some(Key::Char(' ')));
assert_eq!(parse_key_token("<Space>", &comma), Some(Key::Char(' ')));
assert_eq!(parse_key_token("<spc>", &comma), Some(Key::Char(' ')));
assert_eq!(
parse_key_sequence("<space>w", &comma).unwrap(),
vec![Key::Char(' '), Key::Char('w')],
);
}
#[test]
fn parse_leader_key_handles_space_comma_and_rejects_recursion() {
assert_eq!(parse_leader_key("<space>"), Some(Key::Char(' ')));
assert_eq!(parse_leader_key("<Space>"), Some(Key::Char(' ')));
assert_eq!(parse_leader_key(","), Some(Key::Char(',')));
assert_eq!(parse_leader_key("\\"), Some(Key::Char('\\')));
assert_eq!(parse_leader_key("<C-a>"), Some(Key::Ctrl('a')));
assert_eq!(parse_leader_key("<leader>"), None);
assert_eq!(parse_leader_key("nope-multichar"), None);
}
#[test]
fn leader_sequence_resolves_against_configured_leader() {
let mut km = Keymap::new();
km.set_leader(Key::Char(' '));
let plan = apply_source(
r#"(defkeybind :mode "normal" :key "<leader>ff" :action "picker.files")"#,
)
.unwrap();
apply_plan_to_keymap(&plan, &mut km);
let space_seq = vec![Key::Char(' '), Key::Char('f'), Key::Char('f')];
assert!(
km.lookup_sequence(Mode::Normal, &space_seq).is_some(),
"<leader>ff should bind under the configured space leader",
);
let comma_seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
assert!(
km.lookup_sequence(Mode::Normal, &comma_seq).is_none(),
"the comma default must NOT capture <leader> once leader is space",
);
}
#[test]
fn resolve_known_action_returns_typed_variant() {
let (a, deferred) = resolve_action("move-left");
assert_eq!(a, Action::Move(Motion::Left));
assert!(!deferred);
}
#[test]
fn resolve_unknown_action_falls_back_to_command() {
let (a, deferred) = resolve_action("goto-home");
assert!(deferred);
match a {
Action::Command { name, .. } => assert_eq!(name, "goto-home"),
other => panic!("expected Action::Command, got {other:?}"),
}
}
#[test]
fn apply_populates_keymap_with_typed_action() {
let plan = apply_source(
r#"
(defkeybind :mode "normal" :key "h" :action "move-left")
(defkeybind :mode "insert" :key "<Esc>" :action "normal")
"#,
)
.unwrap();
let mut km = Keymap::new();
let report = apply_plan_to_keymap(&plan, &mut km);
assert_eq!(report.keybinds_applied, 2);
assert_eq!(report.keybinds_deferred_to_commands, 0);
assert!(report.warnings.is_empty());
let b = km.lookup(Mode::Normal, &Key::Char('h')).unwrap();
assert_eq!(b.action, Action::Move(Motion::Left));
}
#[test]
fn apply_defers_unknown_action_to_command_registry() {
let plan =
apply_source(r#"(defkeybind :mode "normal" :key "g" :action "goto-home")"#).unwrap();
let mut km = Keymap::new();
let report = apply_plan_to_keymap(&plan, &mut km);
assert_eq!(report.keybinds_applied, 1);
assert_eq!(report.keybinds_deferred_to_commands, 1);
}
#[test]
fn apply_binds_multi_key_sequences_into_sequence_table() {
let plan = apply_source(
r#"
(defkeybind :mode "normal" :key "gh" :action "doc-start")
(defkeybind :mode "normal" :key "<leader>ff" :action "picker.files")
(defkeybind :mode "normal" :key "<leader>fg" :action "picker.grep")
(defkeybind :mode "normal" :key "<Leader>fb" :action "picker.buffers")
"#,
)
.unwrap();
let mut km = Keymap::new();
let report = apply_plan_to_keymap(&plan, &mut km);
assert_eq!(report.keybinds_applied, 4);
assert_eq!(report.keybinds_sequences, 4);
assert_eq!(report.keybinds_deferred_to_commands, 3);
assert!(report.warnings.is_empty());
let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
let b = km
.lookup_sequence(Mode::Normal, &seq)
.expect("<leader>ff should be bound");
assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
let gh = vec![Key::Char('g'), Key::Char('h')];
assert_eq!(
km.lookup_sequence(Mode::Normal, &gh).unwrap().action,
Action::Move(Motion::DocStart),
);
}
#[test]
fn apply_still_warns_on_truly_unrecognised_keys() {
let plan =
apply_source(r#"(defkeybind :mode "normal" :key "<Galactus>" :action "home")"#).unwrap();
let mut km = Keymap::new();
let report = apply_plan_to_keymap(&plan, &mut km);
assert_eq!(report.keybinds_applied, 0);
assert_eq!(report.keybinds_sequences, 0);
assert_eq!(report.warnings.len(), 1);
}
#[test]
fn apply_commands_registers_defcmd_into_registry() {
let plan = apply_source(
r#"
(defcmd :name "write-all" :description "Write every modified buffer" :action "buffer.write-all")
(defcmd :name "pick-files" :description "Pick a file" :action "picker.files")
"#,
)
.unwrap();
let mut registry = CommandRegistry::default_set();
let before = registry.len();
let report = apply_plan_to_commands(&plan, &mut registry);
assert_eq!(report.registered, 2);
assert_eq!(report.overridden, 0);
assert!(registry.contains("write-all"));
assert!(registry.contains("pick-files"));
assert_eq!(registry.len(), before + 2);
}
#[test]
fn apply_commands_defcmd_overrides_builtin_last_writer_wins() {
let plan =
apply_source(r#"(defcmd :name "save" :description "Save, formatted" :action "buffer.write")"#)
.unwrap();
let mut registry = CommandRegistry::default_set();
let report = apply_plan_to_commands(&plan, &mut registry);
assert_eq!(report.registered, 0);
assert_eq!(report.overridden, 1);
assert_eq!(report.overridden_names, vec!["save".to_string()]);
}
#[test]
fn apply_commands_empty_plan_is_noop() {
let plan = apply_source("").unwrap();
let mut registry = CommandRegistry::default_set();
let before = registry.len();
let report = apply_plan_to_commands(&plan, &mut registry);
assert_eq!(report.registered, 0);
assert_eq!(report.overridden, 0);
assert_eq!(registry.len(), before);
}
#[test]
fn apply_options_sets_and_overrides() {
let plan = apply_source(
r#"
(defoption :name "number" :value "true")
(defoption :name "tabstop" :value "4")
(defoption :name "number" :value "false")
"#,
)
.unwrap();
let mut opts = std::collections::HashMap::new();
let report = apply_plan_to_options(&plan, &mut opts);
assert_eq!(report.set, 2);
assert_eq!(report.overridden, 1);
assert_eq!(opts.get("number").map(String::as_str), Some("false"));
assert_eq!(opts.get("tabstop").map(String::as_str), Some("4"));
}
#[test]
fn grammar_apply_registers_known_and_reports_unknown() {
let plan = apply_source(
r#"
(defmode :name "rust" :tree-sitter "rust" :extensions ("rs" "rs.in"))
(defmode :name "nix" :tree-sitter "nix" :extensions ("nix"))
(defmode :name "plain" :extensions ("txt"))
"#,
)
.unwrap();
let known_langs = ["rust"];
let mut registered: Vec<(String, String)> = Vec::new();
let report = apply_plan_to_grammar_extensions(
&plan,
|name| known_langs.iter().any(|k| *k == name),
|lang, ext| registered.push((lang.to_string(), ext.to_string())),
);
assert_eq!(report.extensions_registered, 2);
assert_eq!(report.extensions_skipped_unknown_language, 1);
assert_eq!(report.unknown_languages, vec!["nix".to_string()]);
assert_eq!(
registered,
vec![
("rust".to_string(), "rs".to_string()),
("rust".to_string(), "rs.in".to_string()),
]
);
}
#[test]
fn grammar_apply_tolerates_defmode_without_tree_sitter() {
let plan = apply_source(r#"(defmode :name "plain" :extensions ("txt" "log"))"#).unwrap();
let report = apply_plan_to_grammar_extensions(
&plan,
|_| true,
|_, _| panic!("should not register anything"),
);
assert_eq!(report.extensions_registered, 0);
assert!(report.unknown_languages.is_empty());
}
#[test]
fn apply_overrides_default_vim_binding() {
let plan =
apply_source(r#"(defkeybind :mode "normal" :key "h" :action "move-right")"#).unwrap();
let mut km = Keymap::default_vim();
let before = km
.lookup(Mode::Normal, &Key::Char('h'))
.cloned()
.expect("default vim should bind h");
assert_eq!(before.action, Action::Move(Motion::Left));
apply_plan_to_keymap(&plan, &mut km);
let after = km.lookup(Mode::Normal, &Key::Char('h')).unwrap();
assert_eq!(after.action, Action::Move(Motion::Right));
}
}