/ tests / spec / nvim_pack_spec.lua
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)