#[derive(Clone, Copy)]
pub enum Handler {
Leaf(fn(&mut crate::editor::Editor, char)),
Alias(&'static str),
Motion,
Operator,
ObjectPrefix,
Prefix,
TextLine,
AbsorbChar(AbsorbKind),
AbsorbRegister,
Soon,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbsorbKind {
Replace,
MarkSet,
MarkJump,
Find,
MacroRecord,
MacroPlay,
}
pub struct Binding {
pub keys: &'static str,
pub desc: &'static str,
pub section: &'static str,
pub live: bool,
pub id: &'static str,
pub handler: Handler,
}
pub const SECTIONS: &[&str] = &["normal", "visual", "insert", "leader", "git", "ex+panes"];
pub const BINDINGS: &[Binding] = &[
Binding {
keys: "h j k l",
desc: "move (never off the line)",
section: "normal",
live: true,
id: "move",
handler: Handler::Motion,
},
Binding {
keys: "w b e W B E",
desc: "word / WORD motions",
section: "normal",
live: true,
id: "word-motions",
handler: Handler::Motion,
},
Binding {
keys: "zz zt zb",
desc: "center/top/bottom the cursor line",
section: "normal",
live: true,
id: "view-place",
handler: Handler::Leaf(|e, k| e.view_place(k)),
},
Binding {
keys: "H M L",
desc: "top/middle/bottom visible line",
section: "normal",
live: true,
id: "visible-jumps",
handler: Handler::Leaf(|e, k| e.jump_visible(k, 1)),
},
Binding {
keys: "ZZ",
desc: "write + close window",
section: "normal",
live: true,
id: "write-quit",
handler: Handler::Leaf(|e, _| e.write_quit()),
},
Binding {
keys: "gv",
desc: "reselect last visual",
section: "normal",
live: true,
id: "reselect-visual",
handler: Handler::Leaf(|e, _| e.reselect_visual()),
},
Binding {
keys: "gi",
desc: "insert at last insert",
section: "normal",
live: true,
id: "insert-at-last",
handler: Handler::Leaf(|e, _| e.insert_at_last()),
},
Binding {
keys: "g;",
desc: "older change (changelist)",
section: "normal",
live: true,
id: "change-back",
handler: Handler::Leaf(|e, _| e.change_jump(true)),
},
Binding {
keys: "g,",
desc: "newer change (changelist)",
section: "normal",
live: true,
id: "change-forward",
handler: Handler::Leaf(|e, _| e.change_jump(false)),
},
Binding {
keys: "ge gE { }",
desc: "word-end back / paragraph motions",
section: "normal",
live: true,
id: "paragraph-motions",
handler: Handler::Motion,
},
Binding {
keys: "0 $ G %",
desc: "line/file/pair jumps",
section: "normal",
live: true,
id: "line-jumps",
handler: Handler::Motion,
},
Binding {
keys: "gg",
desc: "top of file",
section: "normal",
live: true,
id: "top",
handler: Handler::Motion,
},
Binding {
keys: "enter",
desc: "line down, first non-blank (blame gutter: dive)",
section: "normal",
live: true,
id: "enter",
handler: Handler::Leaf(|e, _| e.enter_pub()),
},
Binding {
keys: "tab",
desc: "jump list forward (ctrl-i)",
section: "normal",
live: true,
id: "jump-forward",
handler: Handler::Leaf(|e, _| e.jump_forward()),
},
Binding {
keys: "ctrl-r",
desc: "redo",
section: "normal",
live: true,
id: "redo",
handler: Handler::Leaf(|e, _| e.redo()),
},
Binding {
keys: "ctrl-d ctrl-u ctrl-f ctrl-b",
desc: "half/full page scroll (count = lines)",
section: "normal",
live: true,
id: "scroll-pages",
handler: Handler::Leaf(|_, _| {}), },
Binding {
keys: "ctrl-^",
desc: "alternate buffer",
section: "normal",
live: true,
id: "alternate-buffer",
handler: Handler::Leaf(|e, _| e.alternate_buffer()),
},
Binding {
keys: "q<a>",
desc: "record macro into register",
section: "normal",
live: true,
id: "macro-record",
handler: Handler::AbsorbChar(AbsorbKind::MacroRecord),
},
Binding {
keys: "@<a>",
desc: "play macro (count repeats)",
section: "normal",
live: true,
id: "macro-play",
handler: Handler::AbsorbChar(AbsorbKind::MacroPlay),
},
Binding {
keys: "gr",
desc: "references (LSP)",
section: "normal",
live: true,
id: "references",
handler: Handler::Leaf(|e, _| e.lsp_locations_pub(strop_lsp::LocKind::References)),
},
Binding {
keys: "gI",
desc: "implementation (LSP)",
section: "normal",
live: true,
id: "implementation",
handler: Handler::Leaf(|e, _| e.lsp_locations_pub(strop_lsp::LocKind::Implementation)),
},
Binding {
keys: "gy",
desc: "type definition (LSP)",
section: "normal",
live: true,
id: "type-definition",
handler: Handler::Leaf(|e, _| e.lsp_locations_pub(strop_lsp::LocKind::TypeDefinition)),
},
Binding {
keys: "gD",
desc: "declaration (LSP)",
section: "normal",
live: true,
id: "declaration",
handler: Handler::Leaf(|e, _| e.lsp_locations_pub(strop_lsp::LocKind::Declaration)),
},
Binding {
keys: "]d [d",
desc: "next/prev diagnostic",
section: "normal",
live: true,
id: "diagnostic-jumps",
handler: Handler::Leaf(|e, k| e.jump_diagnostic_pub(k != '[')),
},
Binding {
keys: "gd",
desc: "goto definition (LSP)",
section: "normal",
live: true,
id: "goto-definition",
handler: Handler::Leaf(|e, _| crate::editor::Editor::lsp_goto_definition_pub(e)),
},
Binding {
keys: "gs",
desc: "switch source/header (clangd)",
section: "normal",
live: true,
id: "switch-source-header",
handler: Handler::Leaf(|e, _| crate::editor::Editor::lsp_switch_source_header_pub(e)),
},
Binding {
keys: "f<c> F<c> t<c> T<c>",
desc: "find/till char (candidates light up)",
section: "normal",
live: true,
id: "find-char",
handler: Handler::AbsorbChar(AbsorbKind::Find),
},
Binding {
keys: ":",
desc: "ex command line",
section: "normal",
live: true,
id: "ex-line",
handler: Handler::TextLine,
},
Binding {
keys: "/ ?",
desc: "search forward / backward",
section: "normal",
live: true,
id: "search",
handler: Handler::TextLine,
},
Binding {
keys: "n",
desc: "next match",
section: "normal",
live: true,
id: "search-next",
handler: Handler::Leaf(|e, _| e.repeat_search_pub(false)),
},
Binding {
keys: "N",
desc: "previous match",
section: "normal",
live: true,
id: "search-prev",
handler: Handler::Leaf(|e, _| e.repeat_search_pub(true)),
},
Binding {
keys: "]c [c",
desc: "next / prev git hunk",
section: "normal",
live: true,
id: "hunk-nav",
handler: Handler::Leaf(|e, k| e.jump_hunk_pub(k != '[')),
},
Binding {
keys: "m<a>",
desc: "set mark at cursor",
section: "normal",
live: true,
id: "mark-set",
handler: Handler::AbsorbChar(AbsorbKind::MarkSet),
},
Binding {
keys: "'<a> `<a>",
desc: "jump to mark",
section: "normal",
live: true,
id: "mark-jump",
handler: Handler::AbsorbChar(AbsorbKind::MarkJump),
},
Binding {
keys: "* #",
desc: "word under cursor, forward / backward",
section: "normal",
live: true,
id: "word-search",
handler: Handler::Leaf(|e, k| e.search_word_under_cursor_pub(k == '#')),
},
Binding {
keys: "; ,",
desc: "repeat find, same / reversed",
section: "normal",
live: true,
id: "find-repeat",
handler: Handler::Leaf(|e, k| e.repeat_find_pub(k == ',')),
},
Binding {
keys: "|",
desc: "column motion (vim); pipe moved to space |",
section: "normal",
live: true,
id: "column-motion",
handler: Handler::Motion,
},
Binding {
keys: "space |",
desc: "pipe line/selection through shell (:! runs)",
section: "leader",
live: true,
id: "pipe-shell",
handler: Handler::TextLine,
},
Binding {
keys: "Q",
desc: "toggle cursor at point (multicursor)",
section: "normal",
live: true,
id: "cursor-toggle",
handler: Handler::Leaf(|e, _| crate::editor::Editor::toggle_cursor(e)),
},
Binding {
keys: "d y c > <",
desc: "operators + motion/object (live preview)",
section: "normal",
live: true,
id: "operators",
handler: Handler::Operator,
},
Binding {
keys: "dd yy cc",
desc: "line delete/yank/change",
section: "normal",
live: true,
id: "line-ops",
handler: Handler::Operator,
},
Binding {
keys: "D",
desc: "delete to line end",
section: "normal",
live: true,
id: "op-alias-d",
handler: Handler::Alias("d$"),
},
Binding {
keys: "C",
desc: "change to line end",
section: "normal",
live: true,
id: "op-alias-c",
handler: Handler::Alias("c$"),
},
Binding {
keys: "Y",
desc: "yank line",
section: "normal",
live: true,
id: "op-alias-y",
handler: Handler::Alias("yy"),
},
Binding {
keys: "s",
desc: "substitute char",
section: "normal",
live: true,
id: "op-alias-s",
handler: Handler::Alias("cl"),
},
Binding {
keys: "x X",
desc: "delete char / char back",
section: "normal",
live: true,
id: "char-delete",
handler: Handler::Leaf(|e, _| crate::editor::Editor::delete_char(e)),
},
Binding {
keys: "iw i\" i' i( i[ i{",
desc: "inner objects (quotes scan the line)",
section: "normal",
live: true,
id: "objects",
handler: Handler::ObjectPrefix,
},
Binding {
keys: "ds\" cs\"' ysiw\"",
desc: "surround: delete / change / add",
section: "normal",
live: true,
id: "surround",
handler: Handler::ObjectPrefix,
},
Binding {
keys: "i a A o O I",
desc: "insert (auto-indent)",
section: "normal",
live: true,
id: "insert-entries",
handler: Handler::Leaf(crate::editor::Editor::insert_entry_pub),
},
Binding {
keys: "p P",
desc: "paste after / before",
section: "normal",
live: true,
id: "paste",
handler: Handler::Leaf(|e, k| e.paste_named_pub(None, k == 'P')),
},
Binding {
keys: "r<c>",
desc: "replace char",
section: "normal",
live: true,
id: "replace-char",
handler: Handler::AbsorbChar(AbsorbKind::Replace),
},
Binding {
keys: "J .",
desc: "join lines · repeat change",
section: "normal",
live: true,
id: "join-repeat",
handler: Handler::Leaf(crate::editor::Editor::join_or_repeat),
},
Binding {
keys: "^",
desc: "first non-blank",
section: "normal",
live: true,
id: "first-non-blank",
handler: Handler::Motion,
},
Binding {
keys: "~",
desc: "toggle case",
section: "normal",
live: true,
id: "toggle-case",
handler: Handler::Leaf(|e, _| crate::editor::Editor::toggle_case_pub(e)),
},
Binding {
keys: "S",
desc: "change line",
section: "normal",
live: true,
id: "subst-line",
handler: Handler::Alias("cc"),
},
Binding {
keys: "u ctrl-r",
desc: "undo / redo (one unit per command)",
section: "normal",
live: true,
id: "undo-redo",
handler: Handler::Leaf(|e, _| crate::editor::Editor::undo(e)),
},
Binding {
keys: "\"+y \"+p \"+P",
desc: "system clipboard: yank / paste after / before",
section: "normal",
live: true,
id: "reg-clipboard",
handler: Handler::AbsorbRegister,
},
Binding {
keys: "\"xy \"xp",
desc: "named register: yank / paste",
section: "normal",
live: true,
id: "reg-named",
handler: Handler::AbsorbRegister,
},
Binding {
keys: "ctrl-v",
desc: "visual block",
section: "normal",
live: true,
id: "visual-block",
handler: Handler::Leaf(|e, _| e.enter_block_pub()),
},
Binding {
keys: "v V",
desc: "visual / visual-line",
section: "normal",
live: true,
id: "visual-enter",
handler: Handler::Leaf(|e, k| e.enter_visual_pub(k)),
},
Binding {
keys: "d y c x > <",
desc: "operate on selection",
section: "visual",
live: true,
id: "visual-ops",
handler: Handler::Operator,
},
Binding {
keys: "S<c>",
desc: "wrap selection in pair",
section: "visual",
live: true,
id: "visual-surround",
handler: Handler::AbsorbChar(AbsorbKind::Replace),
},
Binding {
keys: "i<a> a<a>",
desc: "objects select (vi[ works)",
section: "visual",
live: true,
id: "visual-objects",
handler: Handler::ObjectPrefix,
},
Binding {
keys: "space y",
desc: "yank selection → clipboard",
section: "visual",
live: true,
id: "clip-yank",
handler: Handler::Leaf(|e, _| e.clipboard_yank_pub()),
},
Binding {
keys: "esc",
desc: "normal mode (session = one undo unit)",
section: "insert",
live: true,
id: "insert-esc",
handler: Handler::Prefix,
},
Binding {
keys: "backspace",
desc: "delete back",
section: "insert",
live: true,
id: "insert-bs",
handler: Handler::Prefix,
},
Binding {
keys: "enter",
desc: "new line (auto-indent)",
section: "insert",
live: true,
id: "insert-enter",
handler: Handler::Prefix,
},
Binding {
keys: "} ] )",
desc: "closer on indent-only line dedents",
section: "insert",
live: true,
id: "insert-closers",
handler: Handler::Prefix,
},
Binding {
keys: "space f",
desc: "file finder",
section: "leader",
live: true,
id: "files",
handler: Handler::Leaf(|e, _| e.open_picker(strop_picker::Kind::Files)),
},
Binding {
keys: "space b",
desc: "buffers (MRU)",
section: "leader",
live: true,
id: "buffers",
handler: Handler::Leaf(|e, _| e.open_picker(strop_picker::Kind::Buffers)),
},
Binding {
keys: "space /",
desc: "live grep",
section: "leader",
live: true,
id: "grep",
handler: Handler::Leaf(|e, _| e.open_picker(strop_picker::Kind::Grep)),
},
Binding {
keys: "space R",
desc: "global search & replace",
section: "leader",
live: true,
id: "replace-global",
handler: Handler::Leaf(|e, _| e.open_picker(strop_picker::Kind::Replace)),
},
Binding {
keys: "space ?",
desc: "this popup",
section: "leader",
live: true,
id: "help",
handler: Handler::Leaf(|e, _| crate::editor::Editor::open_help(e)),
},
Binding {
keys: "space y",
desc: "yank motion → system clipboard",
section: "leader",
live: true,
id: "clip-yank",
handler: Handler::Leaf(|e, _| e.clipboard_yank_pub()),
},
Binding {
keys: "space p",
desc: "paste clipboard after",
section: "leader",
live: true,
id: "clip-paste",
handler: Handler::Leaf(|e, k| e.clipboard_paste_pub(k == 'P')),
},
Binding {
keys: "space P",
desc: "paste clipboard before",
section: "leader",
live: true,
id: "clip-paste-before",
handler: Handler::Leaf(|e, k| e.clipboard_paste_pub(k == 'P')),
},
Binding {
keys: "space d",
desc: "diagnostics picker",
section: "leader",
live: true,
id: "diagnostics",
handler: Handler::Leaf(|e, _| e.open_diagnostics_picker()),
},
Binding {
keys: "space k",
desc: "hover docs",
section: "leader",
live: true,
id: "hover",
handler: Handler::Leaf(|e, _| e.lsp_hover_pub()),
},
Binding {
keys: "space j",
desc: "jumplist picker",
section: "leader",
live: false,
id: "jumplist-picker",
handler: Handler::Soon,
},
Binding {
keys: "space u",
desc: "undo-tree browser",
section: "leader",
live: true,
id: "undo-tree",
handler: Handler::Leaf(|e, _| crate::editor::Editor::open_undo_tree(e)),
},
Binding {
keys: "space c",
desc: "cursor on next line too (multicursor)",
section: "leader",
live: true,
id: "cursor-stack",
handler: Handler::Leaf(|e, _| crate::editor::Editor::add_cursor_next_line(e)),
},
Binding {
keys: "space g",
desc: "git…",
section: "git",
live: true,
id: "git-prefix",
handler: Handler::Prefix,
},
Binding {
keys: "space g l",
desc: "commit browser",
section: "git",
live: true,
id: "git-log",
handler: Handler::Leaf(|e, _| e.open_log_pub(false)),
},
Binding {
keys: "space g h",
desc: "file history (visual: selected lines)",
section: "git",
live: true,
id: "git-file-history",
handler: Handler::Leaf(|e, _| e.open_log_pub(true)),
},
Binding {
keys: "space g b",
desc: "toggle blame gutter / card",
section: "git",
live: true,
id: "git-blame",
handler: Handler::Leaf(|e, _| e.toggle_blame_gutter()),
},
Binding {
keys: "space g y",
desc: "permalink: copy",
section: "git",
live: true,
id: "git-permalink-yank",
handler: Handler::Leaf(|e, _| e.yank_permalink()),
},
Binding {
keys: "space g o",
desc: "permalink: open",
section: "git",
live: true,
id: "git-permalink-open",
handler: Handler::Leaf(|e, _| e.open_permalink()),
},
Binding {
keys: "space g u",
desc: "hunk: undo unstaged (restore from index)",
section: "git",
live: true,
id: "git-hunk-undo",
handler: Handler::Leaf(|e, _| e.undo_hunk()),
},
Binding {
keys: "space g s",
desc: "hunk: stage (live→index)",
section: "git",
live: true,
id: "git-hunk-stage",
handler: Handler::Leaf(|e, _| e.stage_hunk()),
},
Binding {
keys: "space g S",
desc: "hunk: unstage (index→HEAD)",
section: "git",
live: true,
id: "git-hunk-unstage",
handler: Handler::Leaf(|e, _| e.unstage_hunk()),
},
Binding {
keys: "space g p",
desc: "hunk: preview",
section: "git",
live: true,
id: "git-hunk-preview",
handler: Handler::Leaf(|e, _| e.preview_hunk()),
},
Binding {
keys: "]f [f",
desc: "next / prev file in commit diff",
section: "git",
live: true,
id: "commit-file-nav",
handler: Handler::Soon,
},
Binding {
keys: "enter",
desc: "dive into the line's commit (blame gutter)",
section: "git",
live: true,
id: "surface-dive",
handler: Handler::Prefix,
},
Binding {
keys: "q",
desc: "close surface (readonly buffers)",
section: "git",
live: true,
id: "surface-close",
handler: Handler::Prefix,
},
Binding {
keys: ":w :q :q! :wq :w {file}",
desc: "write / quit (force) / write-quit / write-as",
section: "ex+panes",
live: true,
id: "ex-write-quit",
handler: Handler::TextLine,
},
Binding {
keys: ":[range]s/a/b/[g] :N :% :N,Md :N,My",
desc: "substitute (literal) / goto line / ranged delete+yank",
section: "ex+panes",
live: true,
id: "ex-ranges",
handler: Handler::TextLine,
},
Binding {
keys: "ctrl-d ctrl-u ctrl-f ctrl-b",
desc: "half/full page scroll (count = lines)",
section: "ex+panes",
live: true,
id: "scroll-pages",
handler: Handler::TextLine,
},
Binding {
keys: ":e",
desc: "edit file",
section: "ex+panes",
live: true,
id: "ex-edit",
handler: Handler::TextLine,
},
Binding {
keys: ":help",
desc: "help buffer (this text — / searches it)",
section: "ex+panes",
live: true,
id: "ex-help",
handler: Handler::TextLine,
},
Binding {
keys: ":vs :sp",
desc: "split vertical / horizontal",
section: "ex+panes",
live: true,
id: "ex-split",
handler: Handler::TextLine,
},
Binding {
keys: "ctrl-w h / l / j / k / w",
desc: "pane move / cycle",
section: "ex+panes",
live: true,
id: "pane-nav",
handler: Handler::Leaf(crate::editor::Editor::pane_move_pub),
},
Binding {
keys: "ctrl-o / ctrl-i (tab)",
desc: "jump back / forward (jumplist)",
section: "ex+panes",
live: true,
id: "jumplist",
handler: Handler::Leaf(|e, _| crate::editor::Editor::jump_back(e)),
},
Binding {
keys: "ctrl-w v / s",
desc: "pane split (vs / sp)",
section: "ex+panes",
live: true,
id: "pane-split",
handler: Handler::Leaf(crate::editor::Editor::split_pub),
},
Binding {
keys: ":view / -R / :set ro,noro",
desc: "readonly browsing",
section: "ex+panes",
live: true,
id: "readonly",
handler: Handler::TextLine,
},
Binding {
keys: "ctrl-w q",
desc: "close pane (last → buffer)",
section: "ex+panes",
live: true,
id: "pane-close",
handler: Handler::Leaf(|e, _| crate::editor::Editor::pane_close_pub(e)),
},
Binding {
keys: "up down left right tab s-tab",
desc: "picker navigation / arrows = hjkl everywhere",
section: "ex+panes",
live: true,
id: "picker-nav",
handler: Handler::Prefix,
},
Binding {
keys: "ctrl-x",
desc: "replace picker: exclude/include match",
section: "ex+panes",
live: true,
id: "replace-exclude",
handler: Handler::Prefix,
},
];
pub(crate) fn expand(keys: &str) -> Vec<Vec<&str>> {
let toks: Vec<&str> = keys.split(' ').filter(|t| !t.is_empty()).collect();
let mut seqs: Vec<Vec<&str>> = Vec::new();
let mut i = 0;
while i < toks.len() {
match toks[i] {
"/" if seqs.is_empty() => seqs.push(vec!["/"]),
"/" => {
let base: Vec<&str> = seqs
.last()
.map(|s| s[..s.len() - 1].to_vec())
.unwrap_or_default();
for alt in &toks[i + 1..] {
if *alt != "/" {
let mut seq = base.clone();
seq.push(alt);
seqs.push(seq);
}
}
break;
}
"space" => {
let end = (i + 1..toks.len())
.find(|&j| toks[j] == "/" && j + 1 < toks.len())
.unwrap_or(toks.len());
seqs.push(toks[i..end].to_vec());
i = end;
continue;
}
"ctrl-w" => {
if let Some(k) = toks.get(i + 1) {
seqs.push(vec!["ctrl-w", k]);
i += 2;
} else {
seqs.push(vec!["ctrl-w"]);
i += 1;
}
continue;
}
t => seqs.push(vec![t]),
}
i += 1;
}
seqs
}
pub(crate) fn key_seqs(row: &Binding) -> Vec<Vec<String>> {
expand(row.keys)
.iter()
.map(|seq| {
let mut out: Vec<String> = Vec::new();
for t in seq {
if t.len() > 1 && !t.starts_with('<') && !t.starts_with(':') && !NAMED.contains(t) {
if let Some(i) = t.find('<') {
for c in t[..i].chars() {
out.push(c.to_string());
}
out.push(t[i..].to_string());
} else {
for c in t.chars() {
out.push(c.to_string());
}
}
} else {
out.push(t.to_string());
}
}
out
})
.collect()
}
const NAMED: &[&str] = &[
"space",
"ctrl-w",
"ctrl-o",
"ctrl-i",
"up",
"down",
"left",
"right",
"tab",
"s-tab",
"esc",
"enter",
"backspace",
"ctrl-r",
"ctrl-x",
"ctrl-d",
"ctrl-u",
"ctrl-f",
"ctrl-b",
"ctrl-^",
"ctrl-v",
];
fn is_placeholder(k: &str) -> bool {
k.len() > 1 && k.starts_with('<')
}
fn seq_matches(seq: &[String], path: &[String]) -> bool {
seq.len() == path.len()
&& seq
.iter()
.zip(path)
.all(|(k, t)| is_placeholder(k) || k == t)
}
fn seq_has_prefix(seq: &[String], path: &[String]) -> bool {
seq.len() > path.len()
&& seq
.iter()
.zip(path)
.all(|(k, t)| is_placeholder(k) || k == t)
}
pub(crate) fn find_row(path: &[String]) -> Option<&'static Binding> {
BINDINGS
.iter()
.find(|b| b.live && key_seqs(b).iter().any(|seq| seq_matches(seq, path)))
}
pub(crate) fn any_child(path: &[String]) -> bool {
BINDINGS
.iter()
.any(|b| b.live && key_seqs(b).iter().any(|seq| seq_has_prefix(seq, path)))
}
pub struct Hint {
pub key: String,
pub desc: &'static str,
pub live: bool,
}
pub fn children_of(prefix: &str, mode: crate::editor::Mode) -> Vec<Hint> {
use crate::editor::Mode;
let sections: &[&str] = match mode {
Mode::Normal => &["normal", "leader", "git", "ex+panes"],
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => &["visual"],
Mode::Insert => &[],
};
let mut cands: Vec<(usize, Hint)> = Vec::new();
for b in BINDINGS.iter().filter(|b| sections.contains(&b.section)) {
for seq in expand(b.keys) {
if let Some(key) = child_key(&seq, prefix) {
let len: usize = seq
.iter()
.map(|t| if *t == "space" { 1 } else { t.len() })
.sum();
cands.push((
len,
Hint {
key,
desc: b.desc,
live: b.live,
},
));
}
}
}
cands.sort_by_key(|(len, _)| *len); let mut out: Vec<Hint> = Vec::new();
for (_, h) in cands {
if !out.iter().any(|x| x.key == h.key) {
out.push(h);
}
}
out
}
fn child_key(seq: &[&str], prefix: &str) -> Option<String> {
let mut flat = String::new();
let mut bounds = Vec::new();
for t in seq {
bounds.push(flat.len());
flat.push_str(if *t == "space" { " " } else { t });
}
let plen = prefix.chars().count();
if plen == 0 || !flat.starts_with(prefix) || flat.chars().count() <= plen {
return None;
}
match bounds.iter().position(|b| *b == plen) {
Some(i) => Some(seq[i].to_string()),
None => {
let i = bounds.iter().rposition(|b| *b < plen)?;
Some(seq[i].chars().skip(plen - bounds[i]).collect())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::{Editor, Mode};
use strop_core::Buffer;
#[test]
fn coverage_shape() {
for section in SECTIONS {
assert!(
BINDINGS.iter().any(|b| b.section == *section),
"section {section} is empty"
);
}
for b in BINDINGS {
assert!(!b.keys.is_empty() && !b.desc.is_empty());
assert!(
!b.desc.contains("(soon)"),
"{}: '(soon)' belongs to live:false, not the desc",
b.keys
);
}
}
#[test]
fn keys_notation_expands() {
assert_eq!(expand("space f"), vec![vec!["space", "f"]]);
assert_eq!(expand("space /"), vec![vec!["space", "/"]]);
assert_eq!(
expand("space g u / s / p"),
vec![
vec!["space", "g", "u"],
vec!["space", "g", "s"],
vec!["space", "g", "p"]
]
);
assert_eq!(expand("/ ?"), vec![vec!["/"], vec!["?"]]);
assert_eq!(expand("h j k l").len(), 4);
assert_eq!(
expand("ctrl-w h / l"),
vec![vec!["ctrl-w", "h"], vec!["ctrl-w", "l"]]
);
for b in BINDINGS {
let seqs = expand(b.keys);
assert!(!seqs.is_empty(), "{}: no sequences", b.keys);
assert!(seqs.iter().all(|s| !s.is_empty()));
}
}
#[test]
fn which_key_children() {
for p in [" ", " g", "g", "[", "]", "m", "'", "`"] {
assert!(
!children_of(p, Mode::Normal).is_empty(),
"no hints for pending {p:?}"
);
}
let space = children_of(" ", Mode::Normal);
let g = space.iter().find(|h| h.key == "g").expect("space g hint");
assert_eq!(g.desc, "git…");
assert!(g.live);
assert!(
space.iter().any(|h| h.key == "j" && !h.live),
"space j renders as soon"
);
let git = children_of(" g", Mode::Normal);
assert_eq!(git.len(), 9); assert!(git.iter().any(|h| h.key == "u" && h.desc.contains("undo")));
assert_eq!(children_of("m", Mode::Normal)[0].key, "<a>");
assert_eq!(children_of("g", Mode::Normal).len(), 13); let v = children_of(" ", Mode::Visual);
assert_eq!(v.len(), 1);
assert_eq!(v[0].key, "y");
assert!(v[0].desc.contains("selection"));
}
const DISPATCHED: &[&str] = &[
"space f",
"space b",
"space /",
"space R",
"space ?",
"space d",
"space k",
"space y",
"space p",
"space P",
"space g u",
"space g s",
"space g S",
"space g p",
"space g l",
"space g h",
"space g b",
"space g y",
"space g o",
"space u",
"Q",
"space c",
"n",
"N",
"*",
"#",
";",
",",
"^",
"~",
"S",
"I",
":view",
"space |",
"gg",
"gd",
"gs",
"]c",
"[c",
"]f",
"[f",
"m<a>",
"'a",
"`a",
"r<c>",
"\"xy",
"\"xp",
"\"+y",
"\"+p",
"\"+P",
":w",
":q",
":q!",
":wq",
":e",
":help",
":e!",
":vs",
":vsplit",
":sp",
":split",
"ctrl-w h",
"ctrl-w l",
"ctrl-w j",
"ctrl-w k",
"ctrl-w w",
"ctrl-w v",
"ctrl-w s",
"ctrl-w q",
"up",
"down",
"left",
"right",
"tab",
"s-tab",
"ctrl-x",
"}",
"]",
")",
"enter",
"esc",
"backspace",
"q",
"S<c>",
"space y",
];
const EX_ALIASES: &[(&str, &str)] = &[(":vsplit", ":vs"), (":split", ":sp"), (":e!", ":e")];
#[test]
fn every_dispatched_sequence_has_a_row() {
for entry in DISPATCHED {
let canonical = EX_ALIASES
.iter()
.find(|(a, _)| a == entry)
.map(|(_, c)| *c)
.unwrap_or(*entry);
let concretized = canonical.replace("<a>", "a").replace("<c>", "x");
let mut toks: Vec<String> = Vec::new();
for tok in concretized.split(' ') {
if NAMED.contains(&tok) || tok.starts_with(':') {
toks.push(tok.to_string());
} else {
toks.extend(tok.chars().map(|c| c.to_string()));
}
}
assert!(
find_row(&toks).is_some(),
"{entry} dispatches but has no BINDINGS row (0003 §5.7)"
);
}
}
#[test]
fn live_rows_dispatch_through_the_table() {
for b in BINDINGS.iter().filter(|b| b.live) {
for seq in expand(b.keys) {
let keys = seq
.iter()
.map(|t| match *t {
"space" => " ".to_string(),
"<a>" => "a".into(),
"<c>" => "x".into(),
t if t.starts_with("ctrl-") || t == "up" || t == "down" || t == "tab" => {
String::new() }
t if t.contains('<') => {
let i = t.find('<').unwrap();
format!("{}x", &t[..i])
}
t => t.to_string(),
})
.collect::<String>();
if keys.is_empty() || keys.starts_with(':') || keys.starts_with('-') {
continue; }
if matches!(b.handler, Handler::Soon) {
continue; }
let mut e = Editor::new(Buffer::from_text("fn f(x) {\n let y = f(x);\n}\n"));
e.set_head(14);
e.feed_text(&keys);
assert!(
!e.message.starts_with("not an editor command"),
"{} (fed as {keys:?}) failed to dispatch — table drift",
b.keys
);
}
}
}
#[test]
fn live_leader_bindings_reach_dispatch() {
for b in BINDINGS
.iter()
.filter(|b| b.live && b.keys.starts_with("space"))
{
for seq in expand(b.keys) {
let keys = seq
.iter()
.map(|t| if *t == "space" { " " } else { t })
.collect::<String>();
let mut e = Editor::new(Buffer::from_text("x\n"));
e.feed_text(&keys);
if !dispatched_something(&e) {
panic!(
"{} (fed as {keys:?}) no-op: msg={:?} pending={:?} prefix={:?}",
b.keys,
e.message,
e.pending,
e.walker.prefix_display()
);
}
}
}
}
fn dispatched_something(e: &Editor) -> bool {
!e.message.is_empty()
|| !e.pending.is_empty()
|| e.picker_open()
|| !e.walker.prefix_display().is_empty()
|| e.clip_paste_pending.is_some()
|| e.osc52.is_some()
|| e.mode != Mode::Normal
|| e.docs.len() != 1
|| e.head() != 0
|| e.hover_card.is_some()
|| e.blame_card.is_some()
}
#[test]
fn parameterized_leaves_reach_dispatch() {
let mut e = Editor::new(Buffer::from_text("one two\n"));
e.feed_text("ma");
assert_eq!(e.message, "mark a set");
e.feed_text("'b");
assert!(e.message.contains("not set"));
let mut e = Editor::new(Buffer::from_text("one two\n"));
e.feed_text("]c");
assert!(
!e.message.is_empty() || e.head() != 0,
"]c must jump or report, never no-op"
);
let mut e = Editor::new(Buffer::from_text("one two\n"));
e.feed_text("gd");
assert!(!e.message.is_empty(), "gd with no LSP must say so");
let mut e = Editor::new(Buffer::from_text("int x;\n"));
e.feed_text("gs");
assert!(!e.message.is_empty(), "gs with no LSP must say so");
let mut e = Editor::new(Buffer::from_text("hello world\n"));
e.feed_text("\"+yiw");
assert_eq!(e.register(Some('+')).0, "hello");
assert!(e.osc52.is_some(), "clipboard yank stages OSC52");
}
}
pub fn compat_report() -> String {
let mut out = String::from(
"# Vim compatibility\n\nGenerated from the command table (`cargo test` pins freshness; \
STROP_REGEN=1 rewrites).\n`✓` ships exactly; `(soon)` is a planned slot.\n",
);
for section in SECTIONS {
out.push_str(&format!("\n## {section}\n\n"));
for b in BINDINGS.iter().filter(|b| b.section == *section) {
let mark = if b.live { "✓" } else { "·" };
let soon = if b.live { "" } else { " (soon)" };
out.push_str(&format!("- `{mark} {}` — {}{}\n", b.keys, b.desc, soon));
}
}
out
}
#[cfg(test)]
mod compat_tests {
#[test]
fn compat_report_is_fresh() {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/vim-compat.md");
let generated = super::compat_report();
if std::env::var_os("STROP_REGEN").is_some() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &generated).unwrap();
}
let checked_in = std::fs::read_to_string(&path)
.expect("docs/vim-compat.md missing — STROP_REGEN=1 cargo test");
assert_eq!(
checked_in, generated,
"docs/vim-compat.md is stale — STROP_REGEN=1 cargo test to regen"
);
}
}