nvim_pack_spec.lua
1 -- Busted spec: ensure every plugin configured in pack_after.lua has a spec in plugins/*.lua. 2 -- Run with: busted tests/spec/nvim_pack_spec.lua (or: task test:lua) 3 -- 4 -- Plugin config lives in lua/plugins/*.lua: each file returns { specs = {...}, config = function() ... end }. 5 -- pack_after.lua calls config() for each plugin in order; it does not require() plugins by their runtime name. 6 -- Workflow when adding a new plugin: 7 -- 1. Add the spec (src, name) and config = function() ... end in lua/plugins/*.lua. 8 -- 2. Add the plugin basename to the order list in pack_after.lua if you need a specific load order. 9 -- REQUIRE_TO_SPEC is used only when pack_after contains require('plugin_runtime_name'); currently unused. 10 11 local repo_root = os.getenv('DOTFILES_HOME') or (os.getenv('HOME') and (os.getenv('HOME') .. '/.dotfiles')) or '.' 12 local nvim_lua = repo_root .. '/nvim/lua/?.lua' 13 local plugins_dir = repo_root .. '/nvim/lua/plugins' 14 local pack_after_path = repo_root .. '/nvim/lua/config/pack_after.lua' 15 16 --- Derive pack spec name from a spec table (spec.name or from src URL). 17 local function spec_name(s) 18 if type(s) ~= 'table' then return nil end 19 if s.name and s.name ~= '' then return s.name end 20 if s.src then 21 local last = s.src:match('/([^/]+)$') 22 if last then return (last:gsub('%.git$', '')) end 23 end 24 return nil 25 end 26 27 --- Collect all declared spec names from nvim/lua/plugins/*.lua (only entries with .src). 28 local function collect_declared_spec_names() 29 local declared = {} 30 local save_path = package.path 31 package.path = nvim_lua .. ';' .. package.path 32 local handle = io.popen('find "' .. plugins_dir .. '" -maxdepth 1 -name "*.lua" -type f 2>/dev/null') 33 if not handle then return declared end 34 local list = handle:read('*a') 35 handle:close() 36 for basename in (list or ''):gmatch('([^/]+)%.lua') do 37 local mod_name = 'plugins.' .. basename 38 package.loaded[mod_name] = nil 39 local ok, mod = pcall(require, mod_name) 40 if ok and type(mod) == 'table' then 41 local raw = mod.specs or mod 42 if type(raw) == 'table' then 43 if raw.src then raw = { raw } end 44 for _, s in ipairs(raw) do 45 if type(s) == 'table' and s.src then 46 local name = spec_name(s) 47 if name then declared[name] = true end 48 end 49 end 50 end 51 end 52 package.loaded[mod_name] = nil 53 end 54 package.path = save_path 55 return declared 56 end 57 58 --- Require-name (from pack_after) -> spec name (as in plugins/*.lua). 59 local REQUIRE_TO_SPEC = { 60 ['sonokai'] = 'sonokai', 61 ['yazi'] = 'yazi.nvim', 62 ['fzf-lua'] = 'fzf-lua', 63 ['gitsigns'] = 'gitsigns.nvim', 64 ['mason'] = 'mason.nvim', 65 ['mason-lspconfig'] = 'mason-lspconfig.nvim', 66 ['fidget'] = 'fidget.nvim', 67 ['lspconfig'] = 'nvim-lspconfig', 68 ['dressing'] = 'dressing.nvim', 69 ['lualine'] = 'lualine.nvim', 70 ['trouble'] = 'trouble.nvim', 71 ['nvim-treesitter.configs'] = 'nvim-treesitter', 72 ['conform'] = 'conform.nvim', 73 ['colorizer'] = 'nvim-colorizer.lua', 74 ['aerial'] = 'aerial.nvim', 75 ['flash'] = 'flash.nvim', 76 ['smartcolumn'] = 'smartcolumn.nvim', 77 ['zen-mode'] = 'zen-mode.nvim', 78 ['ibl'] = 'indent-blankline.nvim', 79 ['ibl.hooks'] = 'indent-blankline.nvim', 80 ['tmux'] = 'tmux.nvim', 81 ['luasnip'] = 'LuaSnip', 82 ['noice'] = 'noice.nvim', 83 ['notify'] = 'nvim-notify', 84 } 85 86 --- Collect plugin names that pack_after.lua configures (require(...) and colorscheme(...)). 87 local function collect_configured_spec_names() 88 local f = io.open(pack_after_path, 'r') 89 if not f then return {} end 90 local content = f:read('*a') 91 f:close() 92 local configured = {} 93 -- colorscheme('sonokai') 94 for name in content:gmatch("colorscheme%(['\"]([^'\"]+)['\"]") do 95 configured[name] = true 96 end 97 -- require('...') and pcall(require, '...') 98 for name in content:gmatch("require%(['\"]([^'\"]+)['\"]") do 99 local spec = REQUIRE_TO_SPEC[name] 100 if spec then configured[spec] = true end 101 end 102 for name in content:gmatch("require%,%s*['\"]([^'\"]+)['\"]") do 103 local spec = REQUIRE_TO_SPEC[name] 104 if spec then configured[spec] = true end 105 end 106 return configured 107 end 108 109 describe('Neovim pack: configured plugins have specs', function() 110 it('every plugin configured in pack_after.lua has a spec in lua/plugins/*.lua', function() 111 local declared = collect_declared_spec_names() 112 local configured = collect_configured_spec_names() 113 local missing = {} 114 for name, _ in pairs(configured) do 115 if not declared[name] then 116 table.insert(missing, name) 117 end 118 end 119 table.sort(missing) 120 assert( 121 #missing == 0, 122 'Plugins configured in pack_after.lua but missing a spec in lua/plugins/*.lua: ' 123 .. table.concat(missing, ', ') 124 .. '. Add a spec (src, name) to one of the plugin files.' 125 ) 126 end) 127 end) 128 129 describe('Neovim pack: plugin specs shape', function() 130 it('every declared spec with .src has a derivable name (spec.name or from src)', function() 131 local save_path = package.path 132 package.path = nvim_lua .. ';' .. package.path 133 local handle = io.popen('find "' .. plugins_dir .. '" -maxdepth 1 -name "*.lua" -type f 2>/dev/null') 134 if not handle then package.path = save_path return end 135 local list = handle:read('*a') 136 handle:close() 137 local bad = {} 138 for basename in (list or ''):gmatch('([^/]+)%.lua') do 139 local mod_name = 'plugins.' .. basename 140 package.loaded[mod_name] = nil 141 local ok, mod = pcall(require, mod_name) 142 if ok and type(mod) == 'table' then 143 local raw = mod.specs or mod 144 if type(raw) == 'table' then 145 if raw.src then raw = { raw } end 146 for _, s in ipairs(raw) do 147 if type(s) == 'table' and s.src and not spec_name(s) then 148 table.insert(bad, basename .. '.lua: spec with src=' .. tostring(s.src):sub(1, 40) .. ' has no name') 149 end 150 end 151 end 152 end 153 package.loaded[mod_name] = nil 154 end 155 package.path = save_path 156 assert(#bad == 0, table.concat(bad, '; ')) 157 end) 158 end)