use regex::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserMap {
pub lhs: String,
pub modes: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanResult {
pub commands: Vec<String>,
pub user_maps: Vec<UserMap>,
pub plug_maps: Vec<String>,
pub user_events: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
Lua,
Vim,
}
impl Dialect {
pub fn from_path(path: &Path) -> Option<Self> {
match path.extension().and_then(|e| e.to_str()) {
Some("lua") => Some(Dialect::Lua),
Some("vim") => Some(Dialect::Vim),
_ => None,
}
}
}
pub fn scan_source(src: &str, dialect: Dialect) -> ScanResult {
let mut out = ScanResult::default();
match dialect {
Dialect::Lua => {
let lua_code = strip_lua_line_comments(src);
scan_lua_commands(&lua_code, &mut out.commands);
scan_lua_maps(&lua_code, &mut out.user_maps, &mut out.plug_maps);
scan_lua_events(&lua_code, &mut out.user_events);
}
Dialect::Vim => {
for line in src.lines() {
scan_vim_command(line, &mut out.commands);
scan_vim_map(line, &mut out.user_maps, &mut out.plug_maps);
scan_vim_event(line, &mut out.user_events);
}
}
}
out
}
fn strip_lua_line_comments(src: &str) -> String {
let bytes = src.as_bytes();
let mut out = String::with_capacity(src.len());
let mut in_str: Option<u8> = None;
let mut escape = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = in_str {
out.push(c as char);
if escape {
escape = false;
} else if c == b'\\' {
escape = true;
} else if c == q {
in_str = None;
}
i += 1;
continue;
}
if c == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' {
let after_dashes = i + 2;
if after_dashes < bytes.len() && bytes[after_dashes] == b'[' {
let mut level = 0usize;
let mut j = after_dashes + 1;
while j < bytes.len() && bytes[j] == b'=' {
level += 1;
j += 1;
}
if j < bytes.len() && bytes[j] == b'[' {
let start = j + 1;
let end = find_long_bracket_close(bytes, start, level);
let slice_end = end.unwrap_or(bytes.len());
out.push(' ');
i = slice_end;
continue;
}
}
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if c == b'"' || c == b'\'' {
in_str = Some(c);
}
out.push(c as char);
i += 1;
}
out
}
fn find_long_bracket_close(bytes: &[u8], from: usize, level: usize) -> Option<usize> {
let mut i = from;
while i < bytes.len() {
if bytes[i] == b']' {
let mut j = i + 1;
let mut eq_count = 0usize;
while j < bytes.len() && bytes[j] == b'=' {
eq_count += 1;
j += 1;
}
if eq_count == level && j < bytes.len() && bytes[j] == b']' {
return Some(j + 1);
}
}
i += 1;
}
None
}
pub fn scan_files<P: AsRef<Path>>(paths: &[P]) -> ScanResult {
let mut commands_seen: HashSet<String> = HashSet::new();
let mut maps_seen: HashSet<(String, Vec<String>)> = HashSet::new();
let mut plug_maps_seen: HashSet<String> = HashSet::new();
let mut events_seen: HashSet<String> = HashSet::new();
let mut agg = ScanResult::default();
for p in paths {
let path = p.as_ref();
let Some(dialect) = Dialect::from_path(path) else {
continue;
};
let Ok(src) = std::fs::read_to_string(path) else {
continue;
};
let res = scan_source(&src, dialect);
for c in res.commands {
if commands_seen.insert(c.clone()) {
agg.commands.push(c);
}
}
for m in res.user_maps {
let key = (m.lhs.clone(), m.modes.clone());
if maps_seen.insert(key) {
agg.user_maps.push(m);
}
}
for p in res.plug_maps {
if plug_maps_seen.insert(p.clone()) {
agg.plug_maps.push(p);
}
}
for e in res.user_events {
if events_seen.insert(e.clone()) {
agg.user_events.push(e);
}
}
}
agg
}
pub fn scan_plugin(plugin_root: &Path) -> ScanResult {
let mut files: Vec<PathBuf> = Vec::new();
for sub in ["plugin", "ftplugin", "after/plugin", "lua"] {
let dir = plugin_root.join(sub);
if !dir.is_dir() {
continue;
}
collect_scan_targets(&dir, &mut files);
}
scan_files(&files)
}
fn collect_scan_targets(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_scan_targets(&path, out);
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "vim" || ext == "lua" {
out.push(path);
}
}
}
fn lua_cmd_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"nvim_create_user_command\s*\(\s*["']([A-Z][A-Za-z0-9_]*)["']"#).unwrap()
})
}
fn scan_lua_commands(code: &str, out: &mut Vec<String>) {
for caps in lua_cmd_re().captures_iter(code) {
out.push(caps[1].to_string());
}
}
fn scan_vim_command(line: &str, out: &mut Vec<String>) {
let trimmed = line.trim_start();
let after_cmd = match trimmed
.strip_prefix("command!")
.or_else(|| trimmed.strip_prefix("command "))
{
Some(s) => s,
None => return,
};
let mut rest = after_cmd.trim_start();
while let Some(remaining) = rest.strip_prefix('-') {
let end = remaining
.find(char::is_whitespace)
.unwrap_or(remaining.len());
rest = remaining[end..].trim_start();
}
if let Some(name) = extract_ident(rest) {
out.push(name);
}
}
fn lua_map_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"vim\.(?:api\.nvim_set_keymap|keymap\.set)\s*\(\s*["'](?P<mode>[nvxiocstl!]*)["']\s*,\s*["'](?P<lhs>[^"']+)["']"#,
)
.unwrap()
})
}
fn scan_lua_maps(code: &str, user: &mut Vec<UserMap>, plug: &mut Vec<String>) {
for caps in lua_map_re().captures_iter(code) {
let mode_str = caps.name("mode").map_or("", |m| m.as_str());
let lhs = caps.name("lhs").map_or("", |m| m.as_str());
if lhs.is_empty() {
continue;
}
let match_end = caps.get(0).map_or(0, |m| m.end());
if let Some(call_end) = find_lua_call_end(code, match_end)
&& has_buffer_option(&code[match_end..call_end])
{
continue;
}
if is_plug_lhs(lhs) {
plug.push(lhs.to_string());
} else {
user.push(UserMap {
lhs: lhs.to_string(),
modes: lua_mode_string_to_list(mode_str),
});
}
}
}
fn find_lua_call_end(code: &str, from: usize) -> Option<usize> {
let bytes = code.as_bytes();
let mut depth: i32 = 1; let mut in_str: Option<u8> = None;
let mut escape = false;
let mut i = from;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = in_str {
if escape {
escape = false;
} else if c == b'\\' {
escape = true;
} else if c == q {
in_str = None;
}
} else {
match c {
b'"' | b'\'' => in_str = Some(c),
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
i += 1;
}
None
}
fn has_buffer_option(segment: &str) -> bool {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r#"(?:^|[\s,{])buffer\s*="#).unwrap());
re.is_match(segment)
}
fn lua_mode_string_to_list(mode_str: &str) -> Vec<String> {
if mode_str.is_empty() {
vec!["n".into(), "v".into(), "o".into()]
} else if mode_str == "!" {
vec!["i".into(), "c".into()]
} else {
mode_str.chars().map(|c| c.to_string()).collect()
}
}
fn vim_map_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"^\s*(?P<prefix>[nvxiocstl]?)(?P<kind>noremap|map)(?P<bang>!?)\s+(?P<rest>.+)$")
.unwrap()
})
}
fn scan_vim_map(line: &str, user: &mut Vec<UserMap>, plug: &mut Vec<String>) {
let Some(caps) = vim_map_re().captures(line) else {
return;
};
let prefix = caps.name("prefix").map_or("", |m| m.as_str());
let bang = caps.name("bang").map_or("", |m| m.as_str());
let rest = caps.name("rest").map_or("", |m| m.as_str());
let modes = vim_map_modes(prefix, bang == "!");
let Some((lhs, has_buffer)) = parse_vim_map_lhs(rest) else {
return;
};
if has_buffer {
return;
}
if is_plug_lhs(&lhs) {
plug.push(lhs);
} else {
user.push(UserMap { lhs, modes });
}
}
fn parse_vim_map_lhs(rest: &str) -> Option<(String, bool)> {
let mut s = rest.trim_start();
let mut has_buffer = false;
while let Some(after_lt) = s.strip_prefix('<') {
let close = after_lt.find('>')?;
let tag = &after_lt[..close];
let lower = tag.to_ascii_lowercase();
match lower.as_str() {
"buffer" => {
has_buffer = true;
s = after_lt[close + 1..].trim_start();
}
"silent" | "expr" | "nowait" | "unique" | "script" | "special" => {
s = after_lt[close + 1..].trim_start();
}
_ => break,
}
}
let end = s.find(char::is_whitespace).unwrap_or(s.len());
let lhs = s[..end].trim();
if lhs.is_empty() {
None
} else {
Some((lhs.to_string(), has_buffer))
}
}
fn vim_map_modes(prefix: &str, bang: bool) -> Vec<String> {
if prefix.is_empty() {
if bang {
vec!["i".into(), "c".into()]
} else {
vec!["n".into(), "v".into(), "o".into()]
}
} else {
vec![prefix.to_string()]
}
}
fn is_plug_lhs(lhs: &str) -> bool {
lhs.to_ascii_lowercase().starts_with("<plug>")
}
fn lua_user_event_string_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"nvim_exec_autocmds\s*\(\s*["']User["']\s*,[\s\S]*?pattern\s*=\s*["'](?P<ev>[^"']+)["']"#,
)
.unwrap()
})
}
fn lua_user_event_table_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"nvim_exec_autocmds\s*\(\s*["']User["']\s*,[\s\S]*?pattern\s*=\s*\{(?P<inner>[^}]*)\}"#,
)
.unwrap()
})
}
fn extract_lua_string_literals(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let chars: Vec<char> = inner.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
let quote = c;
i += 1;
let start = i;
while i < chars.len() && chars[i] != quote {
i += 1;
}
if i < chars.len() {
let s: String = chars[start..i].iter().collect();
out.push(s);
i += 1; }
} else {
i += 1;
}
}
out
}
fn scan_lua_events(code: &str, out: &mut Vec<String>) {
for caps in lua_user_event_string_re().captures_iter(code) {
out.push(caps["ev"].to_string());
}
for caps in lua_user_event_table_re().captures_iter(code) {
for name in extract_lua_string_literals(&caps["inner"]) {
out.push(name);
}
}
}
fn vim_doautocmd_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\s*doautocmd(?:\s+<[^>]+>)*\s+User\s+(?P<ev>\S+)").unwrap())
}
fn scan_vim_event(line: &str, out: &mut Vec<String>) {
if let Some(caps) = vim_doautocmd_re().captures(line) {
out.push(caps["ev"].to_string());
}
}
fn extract_ident(s: &str) -> Option<String> {
let end = s
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(s.len());
let name = &s[..end];
if is_valid_command_name(name) {
Some(name.to_string())
} else {
None
}
}
fn is_valid_command_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_uppercase() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn suggest_cmd_triggers_smart(commands: &[String], min_prefix: usize) -> Vec<String> {
if commands.is_empty() {
return Vec::new();
}
let mut sorted: Vec<&str> = commands.iter().map(|s| s.as_str()).collect();
sorted.sort();
sorted.dedup();
let mut out = Vec::new();
let mut cluster_start = 0usize;
let mut cluster_lcp: &str = sorted[0];
for i in 1..sorted.len() {
let new_lcp = common_prefix(cluster_lcp, sorted[i]);
if new_lcp.chars().count() >= min_prefix {
cluster_lcp = new_lcp;
} else {
emit_cluster(&sorted[cluster_start..i], cluster_lcp, min_prefix, &mut out);
cluster_start = i;
cluster_lcp = sorted[i];
}
}
emit_cluster(&sorted[cluster_start..], cluster_lcp, min_prefix, &mut out);
out
}
fn emit_cluster(cluster: &[&str], lcp: &str, min_prefix: usize, out: &mut Vec<String>) {
if cluster.len() >= 2 && lcp.chars().count() >= min_prefix {
out.push(format!("/^{}/", regex::escape(lcp)));
} else {
for c in cluster {
out.push((*c).to_string());
}
}
}
fn common_prefix<'a>(a: &'a str, b: &str) -> &'a str {
let mut end = 0;
for (ac, bc) in a.chars().zip(b.chars()) {
if ac == bc {
end += ac.len_utf8();
} else {
break;
}
}
&a[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_source_picks_lua_nvim_create_user_command() {
let src = r#"
vim.api.nvim_create_user_command("FooOne", function() end, { bang = true })
vim.api.nvim_create_user_command('FooTwo', function() end, {})
require('foo').bar("NotCmd")
"#;
let mut out = scan_source(src, Dialect::Lua).commands;
out.sort();
assert_eq!(out, vec!["FooOne", "FooTwo"]);
}
#[test]
fn scan_source_picks_vim_command_bang_and_options() {
let src = r#"
command! FooOne echo 'one'
command! -bang -nargs=* FooTwo echo 'two'
command -bar FooThree echo 'three'
command! -complete=file -nargs=1 FooFour echo 'four'
"#;
let mut out = scan_source(src, Dialect::Vim).commands;
out.sort();
assert_eq!(out, vec!["FooFour", "FooOne", "FooThree", "FooTwo"]);
}
#[test]
fn scan_source_ignores_lua_comment_out_definitions() {
let src = r#"
-- example: vim.api.nvim_create_user_command("Example", function() end)
vim.api.nvim_create_user_command("Real", function() end, {})
"#;
assert_eq!(scan_source(src, Dialect::Lua).commands, vec!["Real"]);
}
#[test]
fn scan_files_preserves_command_duplicates_across_dialects() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.lua");
let b = tmp.path().join("b.vim");
std::fs::write(
&a,
r#"vim.api.nvim_create_user_command("Foo", function() end)"#,
)
.unwrap();
std::fs::write(&b, "command! Foo echo 'same name'").unwrap();
assert_eq!(scan_files(&[a, b]).commands, vec!["Foo"]);
}
#[test]
fn scan_source_picks_vim_nnoremap_lhs() {
let src = "nnoremap gc <Plug>(commentary)\nnnoremap gcc <Plug>(commentary-line)";
let maps = scan_source(src, Dialect::Vim).user_maps;
assert_eq!(
maps,
vec![
UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
},
UserMap {
lhs: "gcc".into(),
modes: vec!["n".into()]
},
]
);
}
#[test]
fn scan_source_strips_silent_option_but_keeps_global_map() {
let src = "nnoremap <silent> gc :echo 'x'<CR>";
let maps = scan_source(src, Dialect::Vim).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
}]
);
}
#[test]
fn scan_source_skips_vim_buffer_local_user_map() {
let src = "nnoremap <silent> <buffer> gc :echo 'x'<CR>";
let result = scan_source(src, Dialect::Vim);
assert!(result.user_maps.is_empty());
assert!(result.plug_maps.is_empty());
}
#[test]
fn scan_source_skips_vim_buffer_local_plug_map() {
let src = "nnoremap <buffer> <Plug>(InternalOnly) :echo 'x'<CR>";
let result = scan_source(src, Dialect::Vim);
assert!(result.user_maps.is_empty());
assert!(result.plug_maps.is_empty());
}
#[test]
fn scan_source_separates_plug_lhs_from_user_maps() {
let src = "nnoremap <Plug>(Foo) :echo 'foo'<CR>\nnnoremap gc <Plug>(Bar)";
let result = scan_source(src, Dialect::Vim);
assert_eq!(
result.user_maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
}]
);
assert_eq!(result.plug_maps, vec!["<Plug>(Foo)"]);
}
#[test]
fn scan_source_extracts_mode_from_vim_prefix() {
let src = "\
vnoremap gc <Plug>(comment)
inoremap gi <Plug>(i-cmd)
xnoremap gx <Plug>(visual)
cnoremap gc :echo 'cmdline'<CR>";
let maps = scan_source(src, Dialect::Vim).user_maps;
assert_eq!(
maps,
vec![
UserMap {
lhs: "gc".into(),
modes: vec!["v".into()]
},
UserMap {
lhs: "gi".into(),
modes: vec!["i".into()]
},
UserMap {
lhs: "gx".into(),
modes: vec!["x".into()]
},
UserMap {
lhs: "gc".into(),
modes: vec!["c".into()]
},
]
);
}
#[test]
fn scan_source_bare_map_default_modes() {
let src = "map gc <Plug>(Foo)";
let maps = scan_source(src, Dialect::Vim).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into(), "v".into(), "o".into()],
}]
);
}
#[test]
fn scan_source_map_bang_is_insert_and_cmdline() {
let src = "noremap! gc <Plug>(Foo)";
let maps = scan_source(src, Dialect::Vim).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["i".into(), "c".into()],
}]
);
}
#[test]
fn scan_source_picks_lua_keymap_set() {
let src = r#"vim.keymap.set("n", "gc", function() end, {})"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
}]
);
}
#[test]
fn scan_source_picks_lua_nvim_set_keymap() {
let src = r#"vim.api.nvim_set_keymap("v", "gv", "<Plug>(Foo)", {})"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gv".into(),
modes: vec!["v".into()]
}]
);
}
#[test]
fn scan_source_filters_lua_plug_lhs() {
let src = r#"vim.keymap.set("n", "<Plug>(Internal)", function() end)"#;
assert!(scan_source(src, Dialect::Lua).user_maps.is_empty());
}
#[test]
fn scan_source_picks_lua_user_event_pattern() {
let src = r#"vim.api.nvim_exec_autocmds("User", { pattern = "FooDone" })"#;
assert_eq!(scan_source(src, Dialect::Lua).user_events, vec!["FooDone"]);
}
#[test]
fn scan_source_picks_vim_doautocmd_user() {
let src = "doautocmd User BarReady";
assert_eq!(scan_source(src, Dialect::Vim).user_events, vec!["BarReady"]);
}
#[test]
fn scan_source_picks_vim_doautocmd_with_options() {
let src = "doautocmd <nomodeline> User BarReady";
assert_eq!(scan_source(src, Dialect::Vim).user_events, vec!["BarReady"]);
}
#[test]
fn scan_source_picks_multiline_lua_create_command() {
let src = r#"
vim.api.nvim_create_user_command(
"MultiFoo",
function() end,
{ bang = true }
)
"#;
assert_eq!(scan_source(src, Dialect::Lua).commands, vec!["MultiFoo"]);
}
#[test]
fn scan_source_picks_multiline_lua_keymap_set() {
let src = r#"
vim.keymap.set(
"n",
"gc",
function() end,
{}
)
"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
}]
);
}
#[test]
fn scan_source_picks_multiline_lua_user_event_string() {
let src = r#"
vim.api.nvim_exec_autocmds("User", {
pattern = "FooDone",
modeline = false,
})
"#;
assert_eq!(scan_source(src, Dialect::Lua).user_events, vec!["FooDone"]);
}
#[test]
fn scan_source_lua_map_multi_char_mode() {
let src = r#"vim.keymap.set("nv", "gc", function() end)"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into(), "v".into()]
}]
);
}
#[test]
fn scan_source_lua_map_empty_mode_defaults_to_nvo() {
let src = r#"vim.keymap.set("", "gc", function() end)"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into(), "v".into(), "o".into()]
}]
);
}
#[test]
fn scan_source_lua_map_bang_mode_is_insert_plus_cmdline() {
let src = r#"vim.keymap.set("!", "gc", function() end)"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["i".into(), "c".into()]
}]
);
}
#[test]
fn scan_source_picks_lua_user_event_table_pattern() {
let src = r#"vim.api.nvim_exec_autocmds("User", { pattern = {"Foo", "Bar"} })"#;
let mut events = scan_source(src, Dialect::Lua).user_events;
events.sort();
assert_eq!(events, vec!["Bar", "Foo"]);
}
#[test]
fn scan_source_picks_multiline_lua_user_event_table_pattern() {
let src = r#"
vim.api.nvim_exec_autocmds("User", {
pattern = {
"AlphaDone",
"BetaReady",
},
})
"#;
let mut events = scan_source(src, Dialect::Lua).user_events;
events.sort();
assert_eq!(events, vec!["AlphaDone", "BetaReady"]);
}
#[test]
fn scan_source_vim_command_keeps_name_when_body_contains_double_dash() {
let src = r#"command! -bang Foo echo '--'"#;
assert_eq!(scan_source(src, Dialect::Vim).commands, vec!["Foo"]);
}
#[test]
fn scan_source_lua_does_not_match_vim_noremap_keyword() {
let src = r#"
vim.keymap.set(mode, key, function()
return handler(key)
end, {
noremap = true,
expr = true,
})
"#;
assert!(scan_source(src, Dialect::Lua).user_maps.is_empty());
}
#[test]
fn scan_source_vim_does_not_process_lua_keymap_set() {
let src = r#"vim.keymap.set("n", "gc", function() end, {})"#;
assert!(scan_source(src, Dialect::Vim).user_maps.is_empty());
}
#[test]
fn scan_source_skips_buffer_local_keymap_set() {
let src = r#"
vim.keymap.set("n", "q", "<cmd>close<CR>", { buffer = bufnr, nowait = true })
vim.keymap.set("n", "<c-c>", "<cmd>close<CR>", { buffer = bufnr })
"#;
assert!(scan_source(src, Dialect::Lua).user_maps.is_empty());
}
#[test]
fn scan_source_keeps_global_keymap_set_without_buffer() {
let src = r#"vim.keymap.set("n", "gc", "<Plug>(commentary)", { desc = "comment" })"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
}]
);
}
#[test]
fn scan_source_skips_buffer_local_multiline_keymap_set() {
let src = r#"
vim.keymap.set("n", "q", "<cmd>close<CR>", {
buffer = bufnr,
nowait = true,
})
"#;
assert!(scan_source(src, Dialect::Lua).user_maps.is_empty());
}
#[test]
fn scan_source_collects_vim_plug_maps() {
let src = "\
nnoremap <Plug>(Foo) :echo 'foo'<CR>
xnoremap <Plug>(BarVisual) :echo 'visual'<CR>
nmap <Plug>(NotNoRemap) <Plug>(Foo)
inoremap gi <Plug>(NotEntry)";
let result = scan_source(src, Dialect::Vim);
assert_eq!(
result.plug_maps,
vec!["<Plug>(Foo)", "<Plug>(BarVisual)", "<Plug>(NotNoRemap)"]
);
assert_eq!(
result.user_maps,
vec![UserMap {
lhs: "gi".into(),
modes: vec!["i".into()]
}]
);
}
#[test]
fn scan_source_collects_lua_plug_maps() {
let src = r#"
vim.keymap.set("n", "<Plug>(LuaFoo)", function() end, {})
vim.api.nvim_set_keymap("v", "<Plug>(LuaBarVisual)", ":echo 'v'<CR>", {})
vim.keymap.set("n", "<leader>g", "<Plug>(NotEntry)", {})
"#;
let result = scan_source(src, Dialect::Lua);
assert_eq!(
result.plug_maps,
vec!["<Plug>(LuaFoo)", "<Plug>(LuaBarVisual)"]
);
assert_eq!(
result.user_maps,
vec![UserMap {
lhs: "<leader>g".into(),
modes: vec!["n".into()]
}]
);
}
#[test]
fn scan_source_skips_buffer_local_plug_map() {
let src = r#"
vim.keymap.set("n", "<Plug>(InternalOnly)", function() end, { buffer = bufnr })
"#;
let result = scan_source(src, Dialect::Lua);
assert!(result.plug_maps.is_empty());
}
#[test]
fn suggest_empty_returns_empty() {
assert!(suggest_cmd_triggers_smart(&[], 3).is_empty());
}
#[test]
fn suggest_single_command_returns_exact_name() {
let out = suggest_cmd_triggers_smart(&["Foo".into()], 3);
assert_eq!(out, vec!["Foo"]);
}
#[test]
fn suggest_two_commands_with_shared_prefix_cluster_as_regex() {
let out = suggest_cmd_triggers_smart(&["ChezmoiEdit".into(), "ChezmoiList".into()], 3);
assert_eq!(out, vec!["/^Chezmoi/"]);
}
#[test]
fn suggest_two_commands_short_lcp_enumerates() {
let out = suggest_cmd_triggers_smart(&["Foo".into(), "Fox".into()], 3);
assert_eq!(out, vec!["Foo", "Fox"]);
}
#[test]
fn suggest_two_unrelated_commands_enumerate() {
let out = suggest_cmd_triggers_smart(&["Foo".into(), "Bar".into()], 3);
assert_eq!(out, vec!["Bar", "Foo"]); }
#[test]
fn suggest_two_clusters_both_become_regex() {
let out = suggest_cmd_triggers_smart(
&["Foo".into(), "FooOne".into(), "Bar".into(), "BarOne".into()],
3,
);
assert_eq!(out, vec!["/^Bar/", "/^Foo/"]);
}
#[test]
fn suggest_three_commands_shared_prefix_single_regex() {
let out = suggest_cmd_triggers_smart(
&[
"GrugFar".into(),
"GrugFarVisual".into(),
"GrugFarWithin".into(),
],
3,
);
assert_eq!(out, vec!["/^GrugFar/"]);
}
#[test]
fn suggest_mixed_cluster_and_singleton() {
let out =
suggest_cmd_triggers_smart(&["Foo".into(), "FooOne".into(), "Standalone".into()], 3);
assert_eq!(out, vec!["/^Foo/", "Standalone"]);
}
#[test]
fn suggest_staircase_keeps_as_singletons() {
let out = suggest_cmd_triggers_smart(&["A".into(), "AB".into(), "ABC".into()], 3);
assert_eq!(out, vec!["A", "AB", "ABC"]);
}
#[test]
fn suggest_dedups_duplicate_commands() {
let out = suggest_cmd_triggers_smart(&["Foo".into(), "Foo".into(), "FooBar".into()], 3);
assert_eq!(out, vec!["/^Foo/"]);
}
#[test]
fn suggest_lcp_uses_char_count_not_byte_count() {
let out = suggest_cmd_triggers_smart(&["日本Foo".into(), "日本Bar".into()], 3);
assert_eq!(out, vec!["日本Bar", "日本Foo"]);
}
#[test]
fn scan_files_dedups_across_sources() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.lua");
let b = tmp.path().join("b.vim");
std::fs::write(
&a,
"vim.api.nvim_create_user_command('Foo', function() end)\n\
vim.api.nvim_create_user_command('Foo', function() end)",
)
.unwrap();
std::fs::write(&b, "command! Foo echo 'b'\nnnoremap gc <Plug>(c)").unwrap();
let result = scan_files(&[a, b]);
assert_eq!(result.commands, vec!["Foo"]);
assert_eq!(
result.user_maps,
vec![UserMap {
lhs: "gc".into(),
modes: vec!["n".into()]
}]
);
}
#[test]
fn scan_plugin_walks_plugin_ftplugin_after_and_lua() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (sub, fname, body) in [
(
"plugin",
"a.lua",
"vim.api.nvim_create_user_command('PluginA', function() end, {})",
),
("ftplugin", "rust.vim", "command! FtRust echo 'rust'"),
(
"after/plugin",
"b.vim",
"command! -bang AfterB echo 'after'",
),
(
"lua/foo",
"init.lua",
r#"return { setup = function() vim.api.nvim_create_user_command("Setupd", function() end, {}) end }"#,
),
("autoload", "x.vim", "command! NotScanned echo 'no'"),
] {
let dir = root.join(sub);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(fname), body).unwrap();
}
let mut out = scan_plugin(root).commands;
out.sort();
assert_eq!(out, vec!["AfterB", "FtRust", "PluginA", "Setupd"]);
}
#[test]
fn scan_source_skips_keymap_set_inside_block_comment() {
let src = r#"
--[[
Example user keybind:
vim.keymap.set("n", "my-key-here", "<Plug>(neorg.foo)", {})
vim.keymap.set("n", "<up>", "<Plug>(neorg.bar)", {})
--]]
vim.keymap.set("n", "real", "<Plug>(plugin.action)", {})
"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "real".into(),
modes: vec!["n".into()],
}],
"block comment body must not produce user_maps entries"
);
}
#[test]
fn scan_source_handles_long_bracket_level_in_block_comment() {
let src = r#"
--[==[
vim.keymap.set("n", "must-be-skipped", "<Plug>(x)")
]==]
vim.keymap.set("n", "kept", "<Plug>(y)")
"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "kept".into(),
modes: vec!["n".into()],
}]
);
}
#[test]
fn scan_source_preserves_string_with_double_dash_inside() {
let src = r#"
vim.keymap.set("n", "?", lib.wrap(display_help, {
{ "--- Quitting ---", "@text.title" },
{ "--- Date Syntax ---", "@text.title" },
}), { buffer = bufnr })
"#;
assert!(scan_source(src, Dialect::Lua).user_maps.is_empty());
}
#[test]
fn scan_source_still_strips_real_line_comment_with_code_after() {
let src = r#"
-- vim.keymap.set("n", "commented-out", …)
vim.keymap.set("n", "live", "<Plug>(x)")
"#;
let maps = scan_source(src, Dialect::Lua).user_maps;
assert_eq!(
maps,
vec![UserMap {
lhs: "live".into(),
modes: vec!["n".into()],
}]
);
}
}