use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub fn scan_source(src: &str) -> Vec<String> {
let mut out = Vec::new();
for line in src.lines() {
let lua_code = line.find("--").map_or(line, |i| &line[..i]);
if let Some(off) = lua_code.find("nvim_create_user_command") {
let rest = &lua_code[off..];
if let Some(open) = rest.find('(') {
let after = rest[open + 1..].trim_start();
if let Some(name) = extract_quoted_ident(after) {
out.push(name);
}
}
}
let trimmed = line.trim_start();
if let Some(after_cmd) = trimmed
.strip_prefix("command!")
.or_else(|| trimmed.strip_prefix("command "))
{
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);
}
}
}
out
}
pub fn scan_files<P: AsRef<Path>>(paths: &[P]) -> Vec<String> {
let mut out = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for p in paths {
let Ok(src) = std::fs::read_to_string(p) else {
continue;
};
for name in scan_source(&src) {
if seen.insert(name.clone()) {
out.push(name);
}
}
}
out
}
pub fn scan_plugin_commands(plugin_root: &Path) -> Vec<String> {
let mut files: Vec<PathBuf> = Vec::new();
for sub in ["plugin", "ftplugin", "after/plugin"] {
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 extract_quoted_ident(s: &str) -> Option<String> {
let first = s.chars().next()?;
if first != '"' && first != '\'' {
return None;
}
let quote = first;
let rest = &s[1..];
let end = rest.find(quote)?;
let name = &rest[..end];
if is_valid_command_name(name) {
Some(name.to_string())
} else {
None
}
}
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 == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_source_picks_lua_nvim_create_user_command() {
let src = r#"
-- plugin/foo.lua
vim.api.nvim_create_user_command("FooOne", function() end, { bang = true })
vim.api.nvim_create_user_command('FooTwo', function() end, {})
-- not a command, just string arg
require('foo').bar("NotCmd")
"#;
let mut out = scan_source(src);
out.sort();
assert_eq!(out, vec!["FooOne", "FooTwo"]);
}
#[test]
fn scan_source_picks_vim_command_bang_and_options() {
let src = r#"
" plugin/foo.vim
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);
out.sort();
assert_eq!(out, vec!["FooFour", "FooOne", "FooThree", "FooTwo"]);
}
#[test]
fn scan_source_requires_uppercase_first_letter() {
let src = r#"
vim.api.nvim_create_user_command("foo", function() end)
command! bar echo 'x'
vim.api.nvim_create_user_command("Foo", function() end)
"#;
assert_eq!(scan_source(src), vec!["Foo"]);
}
#[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), vec!["Real"]);
}
#[test]
fn scan_source_preserves_duplicates() {
let src = r#"
vim.api.nvim_create_user_command("Foo", function() end)
command! Foo echo 'same name'
vim.api.nvim_create_user_command("Foo", function() end)
"#;
assert_eq!(scan_source(src), vec!["Foo", "Foo", "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'").unwrap();
assert_eq!(scan_files(&[a, b]), vec!["Foo"]);
}
#[test]
fn scan_files_aggregates_across_files() {
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('AlphaCmd', function() end, {})",
)
.unwrap();
std::fs::write(&b, "command! BetaCmd echo 'b'").unwrap();
let mut out = scan_files(&[a, b]);
out.sort();
assert_eq!(out, vec!["AlphaCmd", "BetaCmd"]);
}
#[test]
fn scan_files_skips_unreadable_without_failing() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real.lua");
std::fs::write(
&real,
"vim.api.nvim_create_user_command('RealCmd', function() end, {})",
)
.unwrap();
let ghost = tmp.path().join("does_not_exist.lua");
assert_eq!(scan_files(&[ghost, real]), vec!["RealCmd"]);
}
#[test]
fn scan_plugin_commands_walks_plugin_and_ftplugin_and_after() {
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/ignored",
"c.lua",
"vim.api.nvim_create_user_command('Ignored', function() end, {})",
),
] {
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_commands(root);
out.sort();
assert_eq!(out, vec!["AfterB", "FtRust", "PluginA"]);
}
}