Add arti-pack plugin manager scaffold
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

- add lua modules for spec parsing, lock state, fs helpers and the installer
- add ArtiPackSync/ArtiPackClean/ArtiPackList commands and help doc
- add headless nvim test harness and specs
- add make test/lint/fmt/pre-commit and patch/minor/major bump targets
- add woodpecker test, lint and pre-commit pipelines
This commit is contained in:
2026-09-08 23:22:15 +10:00
parent 0d53991048
commit 46517953e5
23 changed files with 1588 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.lua]
indent_style = space
indent_size = 2
+1
View File
@@ -0,0 +1 @@
doc/tags
+3
View File
@@ -0,0 +1,3 @@
std = "lua51"
globals = { "vim" }
max_line_length = 120
+10
View File
@@ -0,0 +1,10 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
+4
View File
@@ -0,0 +1,4 @@
column_width = 120
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
+19
View File
@@ -0,0 +1,19 @@
when:
- event: pull_request
steps:
- name: lint
image: alpine:3.22
commands:
- apk add --no-cache luacheck make
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+19
View File
@@ -0,0 +1,19 @@
when:
- event: pull_request
steps:
- name: test
image: alpine:3.22
commands:
- apk add --no-cache neovim curl unzip tar zip make
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+36
View File
@@ -0,0 +1,36 @@
.PHONY: test lint fmt pre-commit patch minor major _tag
test:
nvim --headless -l tests/run.lua
lint:
luacheck lua tests
fmt:
stylua lua tests
pre-commit:
@uvx pre-commit run --all-files
# Bump helpers — read the latest semver tag and create the next one.
# If no tag exists yet, start from v0.0.0.
_LATEST = $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE = $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ = $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN = $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT = $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag -a $$NEW -m "Release $$NEW" && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag -a $$NEW -m "Release $$NEW" && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag -a $$NEW -m "Release $$NEW" && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+96 -1
View File
@@ -1,3 +1,98 @@
# arti-pack
Neovim plugin manager that installs plugins from tagged archives proxied through artifactapi into nvim's native package path
A Neovim plugin manager that installs plugins from an artifact store instead of
git. It downloads tagged upstream source archives through
[artifactapi](https://artifactapi.k8s.syd1.au.unkin.net) and unpacks them into
Neovim's native package path — see `:h packages`.
No git, no submodules, no network access to GitHub from the editor: every
plugin arrives as a cached, immutable, tagged artifact from the same proxy that
serves RPMs, containers and Terraform providers.
## Requirements
- Neovim 0.10+
- `curl`, and `unzip` (or `tar` when using `ext = 'tar.gz'`)
## Install
arti-pack manages itself least awkwardly as a plain clone into the package path:
```sh
git clone https://git.unkin.net/unkin/arti-pack \
"${XDG_DATA_HOME:-$HOME/.local/share}/nvim/site/pack/arti-pack/start/arti-pack"
```
## Usage
```lua
require('arti-pack').setup({
registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github',
plugins = {
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
{ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' },
{ 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' },
},
})
```
Every plugin needs an explicit `tag` — that is the point. Plugins install to
`stdpath('data')/site/pack/arti-pack/start/<name>`, or `.../opt/<name>` when
`opt = true`.
### Spec fields
| Field | Default | Meaning |
| ------ | ---------- | ---------------------------------------------------- |
| `[1]` | *required* | `owner/repo` |
| `tag` | *required* | Upstream tag, used verbatim in the archive path |
| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` |
| `as` | repo name | Directory name to install as |
| `ext` | `'zip'` | `zip` or `tar.gz` |
| `url` | — | Full archive URL, bypassing the registry |
### Options
| Option | Default | Meaning |
| ------------- | ------- | ------------------------------------------- |
| `registry` | the estate artifactapi github remote | Proxy prefix placed in front of `owner/repo` |
| `auto_sync` | `true` | Sync missing and changed plugins on startup |
| `concurrency` | `4` | Plugins downloaded in parallel |
| `timeout` | `120` | Per-download timeout in seconds |
### Commands
| Command | Effect |
| ---------------- | ---------------------------------------------------------- |
| `:ArtiPackSync` | Install missing plugins and update ones whose tag changed |
| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state |
| `:ArtiPackClean` | Remove installed plugins no longer in the spec |
| `:ArtiPackList` | Show every configured plugin and its state |
## How it resolves an archive
```
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
```
which for the defaults is
```
https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip
```
Archives are cached under `stdpath('cache')/arti-pack`, checked for a real zip
or gzip magic header before being trusted, and unpacked into a staging
directory. Tag archives wrap their contents in a single `<repo>-<version>`
directory, which is stripped on the way in. Installed tags are recorded in
`stdpath('data')/arti-pack/lock.json`, so a sync with nothing to do costs
nothing. Helptags are regenerated per plugin after install.
A failed plugin is reported and skipped; the rest of the run continues.
## Tests
```sh
make test
```
+82
View File
@@ -0,0 +1,82 @@
*arti-pack.txt* Install Neovim plugins from an artifact store
==============================================================================
CONTENTS *arti-pack-contents*
1. Introduction ......................... |arti-pack-introduction|
2. Setup ................................ |arti-pack-setup|
3. Plugin specs ......................... |arti-pack-specs|
4. Commands ............................. |arti-pack-commands|
5. Layout ............................... |arti-pack-layout|
==============================================================================
1. INTRODUCTION *arti-pack-introduction*
arti-pack installs plugins from tagged upstream source archives served through
artifactapi, and unpacks them into Neovim's native package path. See |packages|
for how Neovim loads what arti-pack writes.
Requires curl, and unzip or tar.
==============================================================================
2. SETUP *arti-pack-setup*
*arti-pack.setup()*
>lua
require('arti-pack').setup({
registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github',
plugins = {
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
},
})
<
Options:
registry Proxy prefix placed in front of "owner/repo".
plugins List of plugin specs. See |arti-pack-specs|.
auto_sync Sync missing and changed plugins on startup. Default true.
concurrency Plugins downloaded in parallel. Default 4.
timeout Per-download timeout in seconds. Default 120.
==============================================================================
3. PLUGIN SPECS *arti-pack-specs*
Each entry is a table whose first element is "owner/repo".
tag Required. Upstream tag, used verbatim in the archive path.
opt Install under opt/ for |:packadd| instead of start/. Default false.
as Directory name to install as. Defaults to the repo name.
ext "zip" or "tar.gz". Default "zip".
url Full archive URL, bypassing the registry.
The archive URL is built as: >
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
<
==============================================================================
4. COMMANDS *arti-pack-commands*
*:ArtiPackSync*
:ArtiPackSync Install missing plugins and update ones whose tag changed.
:ArtiPackSync! Reinstall every configured plugin, ignoring recorded state.
*:ArtiPackClean*
:ArtiPackClean Remove installed plugins that are no longer in the spec.
*:ArtiPackList*
:ArtiPackList Show every configured plugin and its state.
==============================================================================
5. LAYOUT *arti-pack-layout*
stdpath('data')/site/pack/arti-pack/start/<name> loaded at startup
stdpath('data')/site/pack/arti-pack/opt/<name> loaded by |:packadd|
stdpath('data')/arti-pack/lock.json recorded tags
stdpath('cache')/arti-pack downloaded archives
Downloads are checked for a zip or gzip magic header before being trusted, and
the single "<repo>-<version>" directory that tag archives wrap their contents
in is stripped on the way in. A plugin that fails is reported and skipped; the
rest of the run continues.
vim:tw=78:ts=8:noet:ft=help:norl:
+88
View File
@@ -0,0 +1,88 @@
local uv = vim.uv or vim.loop
local M = {}
local ZIP_MAGIC = 'PK\3\4'
local GZIP_MAGIC = '\31\139'
function M.join(...)
return table.concat({ ... }, '/')
end
function M.mkdirp(path)
vim.fn.mkdir(path, 'p')
return path
end
function M.rm_rf(path)
if uv.fs_stat(path) then
vim.fn.delete(path, 'rf')
end
end
function M.exists(path)
return uv.fs_stat(path) ~= nil
end
function M.entries(dir)
local handle = uv.fs_scandir(dir)
if not handle then
return {}
end
local out = {}
while true do
local name, kind = uv.fs_scandir_next(handle)
if not name then
break
end
table.insert(out, { name = name, kind = kind })
end
return out
end
-- Tag archives wrap everything in a single "<repo>-<tag>" directory. Detect it
-- rather than reconstructing the name, since the leading "v" is dropped.
function M.archive_root(dir)
local entries = M.entries(dir)
if #entries == 1 and entries[1].kind == 'directory' then
return M.join(dir, entries[1].name)
end
return dir
end
function M.read_head(path, bytes)
local fd = io.open(path, 'rb')
if not fd then
return nil
end
local head = fd:read(bytes)
fd:close()
return head
end
function M.verify_archive(path, ext)
local stat = uv.fs_stat(path)
if not stat or stat.size == 0 then
return false, 'downloaded archive is empty'
end
local magic = ext == 'zip' and ZIP_MAGIC or GZIP_MAGIC
local head = M.read_head(path, #magic)
if head ~= magic then
return false, string.format('downloaded archive is not a %s', ext)
end
return true
end
function M.extract_cmd(archive, dest, ext)
if ext == 'zip' then
return { 'unzip', '-q', '-o', archive, '-d', dest }
end
return { 'tar', '-xzf', archive, '-C', dest }
end
function M.rename(from, to)
return uv.fs_rename(from, to)
end
return M
+220
View File
@@ -0,0 +1,220 @@
local fs = require('arti-pack.fs')
local installer = require('arti-pack.installer')
local Lock = require('arti-pack.lock')
local spec_mod = require('arti-pack.spec')
local M = {}
local DEFAULTS = {
registry = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github',
plugins = {},
auto_sync = true,
concurrency = 4,
timeout = 120,
}
M.config = vim.deepcopy(DEFAULTS)
local running = false
local function paths()
local data = vim.fn.stdpath('data')
return {
pack_dir = fs.join(data, 'site', 'pack', 'arti-pack'),
lock_path = fs.join(data, 'arti-pack', 'lock.json'),
cache_dir = fs.join(vim.fn.stdpath('cache'), 'arti-pack'),
}
end
local function notify(msg, level)
vim.notify('[arti-pack] ' .. msg, level or vim.log.levels.INFO)
end
local function context()
local p = paths()
return {
registry = M.config.registry,
pack_dir = p.pack_dir,
cache_dir = p.cache_dir,
concurrency = M.config.concurrency,
timeout = M.config.timeout,
lock = Lock.new(p.lock_path):load(),
}
end
local function installed_dir(ctx, name, opt)
return fs.join(ctx.pack_dir, opt and 'opt' or 'start', name)
end
local function finalise(ctx, results)
local counts = { installed = 0, updated = 0, failed = 0 }
for _, res in ipairs(results) do
counts[res.status] = (counts[res.status] or 0) + 1
if res.status == 'failed' then
notify(string.format('%s: %s', res.name, res.err), vim.log.levels.ERROR)
end
end
ctx.lock:save()
for _, res in ipairs(results) do
if res.status ~= 'failed' then
local entry = ctx.lock:get(res.name)
local dir = installed_dir(ctx, res.name, entry and entry.opt)
local doc = fs.join(dir, 'doc')
if fs.exists(doc) then
pcall(vim.cmd, 'helptags ' .. vim.fn.fnameescape(doc))
end
if not (entry and entry.opt) then
pcall(vim.cmd.packadd, res.name)
end
end
end
local parts = {}
for _, key in ipairs({ 'installed', 'updated', 'failed' }) do
if counts[key] > 0 then
table.insert(parts, string.format('%d %s', counts[key], key))
end
end
if #parts == 0 then
notify('everything up to date')
else
notify(table.concat(parts, ', '), counts.failed > 0 and vim.log.levels.WARN or vim.log.levels.INFO)
end
end
function M.sync(opts)
opts = opts or {}
if running then
return notify('a sync is already running', vim.log.levels.WARN)
end
local specs, errors = spec_mod.parse_all(M.config.plugins)
for _, err in ipairs(errors) do
notify(err, vim.log.levels.ERROR)
end
local ctx = context()
local pending = {}
for _, spec in ipairs(specs) do
local current = ctx.lock:is_current(spec) and fs.exists(installed_dir(ctx, spec.name, spec.opt))
if opts.force or not current then
table.insert(pending, spec)
end
end
if #pending == 0 then
return notify('everything up to date')
end
running = true
notify(string.format('syncing %d plugin(s)', #pending))
installer.run(ctx, pending, function(results)
running = false
finalise(ctx, results)
end)
end
function M.clean()
local specs = spec_mod.parse_all(M.config.plugins)
local wanted = {}
for _, spec in ipairs(specs) do
wanted[spec.name] = true
end
local ctx = context()
local removed = {}
for _, sub in ipairs({ 'start', 'opt' }) do
local root = fs.join(ctx.pack_dir, sub)
for _, entry in ipairs(fs.entries(root)) do
if not wanted[entry.name] then
fs.rm_rf(fs.join(root, entry.name))
table.insert(removed, entry.name)
end
end
end
for _, name in ipairs(ctx.lock:names()) do
if not wanted[name] then
ctx.lock:remove(name)
end
end
ctx.lock:save()
if #removed == 0 then
notify('nothing to clean')
else
table.sort(removed)
notify('removed ' .. table.concat(removed, ', '))
end
end
function M.list()
local specs, errors = spec_mod.parse_all(M.config.plugins)
local ctx = context()
local lines = {}
for _, spec in ipairs(specs) do
local entry = ctx.lock:get(spec.name)
local state
if not entry then
state = 'missing'
elseif not ctx.lock:is_current(spec) then
state = 'outdated (' .. tostring(entry.tag) .. ')'
elseif not fs.exists(installed_dir(ctx, spec.name, spec.opt)) then
state = 'gone from disk'
else
state = 'installed'
end
table.insert(
lines,
string.format('%-32s %-14s %-5s %s', spec.source, spec.tag, spec.opt and 'opt' or 'start', state)
)
end
for _, name in ipairs(ctx.lock:names()) do
local known = false
for _, spec in ipairs(specs) do
known = known or spec.name == name
end
if not known then
table.insert(lines, string.format('%-32s %-14s %-5s %s', name, '-', '-', 'orphaned'))
end
end
for _, err in ipairs(errors) do
table.insert(lines, 'error: ' .. err)
end
if #lines == 0 then
lines = { 'no plugins configured' }
end
notify(table.concat(lines, '\n'))
return lines
end
function M.setup(opts)
opts = opts or {}
M.config = vim.tbl_deep_extend('force', vim.deepcopy(DEFAULTS), opts)
-- tbl_deep_extend merges lists element-wise, which would splice defaults into
-- a user's plugin list; the caller's list is always authoritative.
M.config.plugins = opts.plugins or {}
local p = paths()
fs.mkdirp(fs.join(p.pack_dir, 'start'))
fs.mkdirp(fs.join(p.pack_dir, 'opt'))
if M.config.auto_sync then
vim.schedule(function()
M.sync()
end)
end
return M
end
return M
+165
View File
@@ -0,0 +1,165 @@
local fs = require('arti-pack.fs')
local spec_mod = require('arti-pack.spec')
local M = {}
local function result(spec, status, err)
return { name = spec.name, source = spec.source, tag = spec.tag, status = status, err = err }
end
local function target_dir(ctx, spec)
return fs.join(ctx.pack_dir, spec.opt and 'opt' or 'start', spec.name)
end
local function stale_dir(ctx, spec)
return fs.join(ctx.pack_dir, spec.opt and 'start' or 'opt', spec.name)
end
local function shell_error(cmd, out)
local stderr = (out.stderr or ''):gsub('%s+$', '')
if stderr == '' then
stderr = (out.stdout or ''):gsub('%s+$', '')
end
if stderr == '' then
stderr = 'exit code ' .. tostring(out.code)
end
return string.format('%s failed: %s', cmd, stderr)
end
local function place(ctx, spec, stage, done)
local root = fs.archive_root(stage)
local target = target_dir(ctx, spec)
fs.mkdirp(vim.fn.fnamemodify(target, ':h'))
fs.rm_rf(target)
fs.rm_rf(stale_dir(ctx, spec))
local ok, err = fs.rename(root, target)
fs.rm_rf(stage)
if not ok then
return done(result(spec, 'failed', 'could not move extracted files into place: ' .. tostring(err)))
end
local previous = ctx.lock:get(spec.name)
ctx.lock:set(spec.name, {
tag = spec.tag,
opt = spec.opt,
source = spec.source,
ext = spec.ext,
url = spec.resolved_url,
installed_at = os.time(),
})
done(result(spec, previous and 'updated' or 'installed'))
end
local function extract(ctx, spec, archive, done)
local stage = fs.join(ctx.pack_dir, '.stage', spec.name)
fs.rm_rf(stage)
fs.mkdirp(stage)
vim.system(fs.extract_cmd(archive, stage, spec.ext), { text = true }, function(out)
vim.schedule(function()
if out.code ~= 0 then
fs.rm_rf(stage)
-- A corrupt cache entry would fail forever; drop it so the next sync refetches.
fs.rm_rf(archive)
return done(result(spec, 'failed', shell_error('extract', out)))
end
place(ctx, spec, stage, done)
end)
end)
end
local function download(ctx, spec, archive, done)
local url, err = spec_mod.url(spec, ctx.registry)
if not url then
return done(result(spec, 'failed', err))
end
spec.resolved_url = url
if fs.exists(archive) and fs.verify_archive(archive, spec.ext) then
return extract(ctx, spec, archive, done)
end
fs.mkdirp(ctx.cache_dir)
local tmp = archive .. '.part'
fs.rm_rf(tmp)
local cmd = {
'curl',
'-fsSL',
'--retry',
'2',
'--max-time',
tostring(ctx.timeout),
'-o',
tmp,
url,
}
vim.system(cmd, { text = true }, function(out)
vim.schedule(function()
if out.code ~= 0 then
fs.rm_rf(tmp)
return done(result(spec, 'failed', string.format('%s: %s', url, shell_error('download', out))))
end
local ok, verr = fs.verify_archive(tmp, spec.ext)
if not ok then
fs.rm_rf(tmp)
return done(result(spec, 'failed', string.format('%s: %s', url, verr)))
end
fs.rm_rf(archive)
local renamed, rerr = fs.rename(tmp, archive)
if not renamed then
fs.rm_rf(tmp)
return done(result(spec, 'failed', 'could not store archive: ' .. tostring(rerr)))
end
extract(ctx, spec, archive, done)
end)
end)
end
function M.install(ctx, spec, done)
local archive = fs.join(ctx.cache_dir, string.format('%s-%s.%s', spec.name, spec.tag, spec.ext))
local ok, err = pcall(download, ctx, spec, archive, done)
if not ok then
done(result(spec, 'failed', tostring(err)))
end
end
-- Failures are per plugin: one bad tag must not abandon the rest of the run.
function M.run(ctx, specs, on_done)
local results, started, finished = {}, 0, 0
local limit = math.max(1, ctx.concurrency or 4)
if #specs == 0 then
return on_done(results)
end
local pump
local function complete(res)
table.insert(results, res)
finished = finished + 1
if finished == #specs then
table.sort(results, function(a, b)
return a.name < b.name
end)
return on_done(results)
end
pump()
end
pump = function()
while started < #specs and (started - finished) < limit do
started = started + 1
M.install(ctx, specs[started], complete)
end
end
pump()
end
return M
+79
View File
@@ -0,0 +1,79 @@
local M = {}
M.__index = M
local function dirname(path)
return path:match('^(.*)[/\\][^/\\]*$') or '.'
end
function M.new(path)
return setmetatable({ path = path, entries = {} }, M)
end
function M:load()
local fd = io.open(self.path, 'r')
if not fd then
self.entries = {}
return self
end
local raw = fd:read('*a')
fd:close()
local ok, decoded = pcall(vim.json.decode, raw)
if not ok or type(decoded) ~= 'table' or type(decoded.plugins) ~= 'table' then
self.entries = {}
return self
end
self.entries = {}
for name, entry in pairs(decoded.plugins) do
if type(name) == 'string' and type(entry) == 'table' then
self.entries[name] = entry
end
end
return self
end
function M:save()
vim.fn.mkdir(dirname(self.path), 'p')
local payload = { version = 1, plugins = self.entries }
local fd, err = io.open(self.path, 'w')
if not fd then
return false, err
end
fd:write(vim.json.encode(payload))
fd:close()
return true
end
function M:get(name)
return self.entries[name]
end
function M:set(name, entry)
self.entries[name] = entry
end
function M:remove(name)
self.entries[name] = nil
end
function M:names()
local names = {}
for name in pairs(self.entries) do
table.insert(names, name)
end
table.sort(names)
return names
end
-- A plugin is current when the recorded tag and load style both still match the
-- spec; a flipped opt flag has to move the directory, so it counts as changed.
function M:is_current(spec)
local entry = self.entries[spec.name]
if not entry then
return false
end
return entry.tag == spec.tag and (entry.opt == true) == (spec.opt == true)
end
return M
+113
View File
@@ -0,0 +1,113 @@
local M = {}
local SOURCE_PATTERN = '^([%w%.%_%-]+)/([%w%.%_%-]+)$'
local EXTENSIONS = { ['zip'] = true, ['tar.gz'] = true }
local function trim(s)
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end
local function bad_name(name)
return name == '' or name == '.' or name == '..' or name:find('[/\\%s]') ~= nil
end
function M.parse(entry)
if type(entry) == 'string' then
entry = { entry }
end
if type(entry) ~= 'table' then
return nil, 'plugin spec must be a string or a table'
end
local source = entry[1] or entry.source
if type(source) ~= 'string' or trim(source) == '' then
return nil, 'plugin spec is missing an "owner/repo" source'
end
source = trim(source)
local owner, repo = source:match(SOURCE_PATTERN)
if not owner then
return nil, string.format('invalid source %q, expected "owner/repo"', source)
end
local tag = entry.tag
if type(tag) ~= 'string' or trim(tag) == '' then
return nil, string.format('%s is missing a tag', source)
end
tag = trim(tag)
if tag:find('[/\\%s]') then
return nil, string.format('%s has an invalid tag %q', source, tag)
end
local ext = entry.ext or 'zip'
if not EXTENSIONS[ext] then
return nil, string.format('%s has an unsupported ext %q', source, tostring(ext))
end
local name = entry.as or repo
if type(name) ~= 'string' or bad_name(name) then
return nil, string.format('%s resolves to an unusable directory name %q', source, tostring(name))
end
if entry.url ~= nil and type(entry.url) ~= 'string' then
return nil, string.format('%s has a non-string url', source)
end
return {
source = source,
owner = owner,
repo = repo,
name = name,
tag = tag,
ext = ext,
opt = entry.opt == true,
url = entry.url,
}
end
-- Tagged source archives live at the same path shape on every GitHub-alike, so
-- the registry is just the proxy prefix in front of "<owner>/<repo>".
function M.url(spec, registry)
if spec.url then
return spec.url
end
if type(registry) ~= 'string' or registry == '' then
return nil, 'registry is not configured'
end
local base = registry:gsub('/+$', '')
return table.concat({
base,
spec.owner,
spec.repo,
'archive',
'refs',
'tags',
spec.tag .. '.' .. spec.ext,
}, '/')
end
function M.parse_all(entries)
local specs, errors, seen = {}, {}, {}
if entries == nil then
return specs, errors
end
if type(entries) ~= 'table' then
return specs, { 'plugins must be a list of specs' }
end
for i, entry in ipairs(entries) do
local spec, err = M.parse(entry)
if not spec then
table.insert(errors, string.format('plugins[%d]: %s', i, err))
elseif seen[spec.name] then
table.insert(errors, string.format('plugins[%d]: duplicate plugin name %q', i, spec.name))
else
seen[spec.name] = true
table.insert(specs, spec)
end
end
return specs, errors
end
return M
+20
View File
@@ -0,0 +1,20 @@
if vim.g.loaded_arti_pack then
return
end
vim.g.loaded_arti_pack = true
local function arti()
return require('arti-pack')
end
vim.api.nvim_create_user_command('ArtiPackSync', function(cmd)
arti().sync({ force = cmd.bang })
end, { bang = true, desc = 'Install missing arti-pack plugins and update changed tags' })
vim.api.nvim_create_user_command('ArtiPackClean', function()
arti().clean()
end, { desc = 'Remove installed plugins that are no longer in the spec' })
vim.api.nvim_create_user_command('ArtiPackList', function()
arti().list()
end, { desc = 'List configured arti-pack plugins and their state' })
+99
View File
@@ -0,0 +1,99 @@
local t = require('tests.harness')
local fs = require('arti-pack.fs')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'wb'))
fd:write(contents)
fd:close()
return path
end
t.describe('fs.archive_root', function()
t.it('strips the single wrapper directory a tag archive unpacks to', function()
local dir = tmpdir()
vim.fn.mkdir(dir .. '/plenary.nvim-0.1.4/lua', 'p')
t.eq(dir .. '/plenary.nvim-0.1.4', fs.archive_root(dir))
end)
t.it('keeps the directory when the archive unpacked flat', function()
local dir = tmpdir()
vim.fn.mkdir(dir .. '/lua', 'p')
write(dir .. '/README.md', 'hi')
t.eq(dir, fs.archive_root(dir))
end)
t.it('keeps the directory when its only entry is a file', function()
local dir = tmpdir()
write(dir .. '/init.lua', 'return {}')
t.eq(dir, fs.archive_root(dir))
end)
end)
t.describe('fs.verify_archive', function()
t.it('accepts a zip magic header', function()
local path = write(tmpdir() .. '/a.zip', 'PK\3\4rest of the file')
t.truthy(fs.verify_archive(path, 'zip'))
end)
t.it('accepts a gzip magic header', function()
local path = write(tmpdir() .. '/a.tar.gz', '\31\139\8\0padding')
t.truthy(fs.verify_archive(path, 'tar.gz'))
end)
t.it('rejects an empty download', function()
local path = write(tmpdir() .. '/a.zip', '')
local ok, err = fs.verify_archive(path, 'zip')
t.falsy(ok)
t.matches('empty', err)
end)
t.it('rejects an html error page served in place of a zip', function()
local path = write(tmpdir() .. '/a.zip', '<!DOCTYPE html><html>404</html>')
local ok, err = fs.verify_archive(path, 'zip')
t.falsy(ok)
t.matches('not a zip', err)
end)
t.it('rejects a zip when a tar.gz was expected', function()
local path = write(tmpdir() .. '/a.tar.gz', 'PK\3\4rest')
local ok, err = fs.verify_archive(path, 'tar.gz')
t.falsy(ok)
t.matches('not a tar%.gz', err)
end)
t.it('rejects a missing file', function()
local ok, err = fs.verify_archive(tmpdir() .. '/absent.zip', 'zip')
t.falsy(ok)
t.matches('empty', err)
end)
end)
t.describe('fs.extract_cmd', function()
t.it('uses unzip into the destination for zips', function()
t.eq({ 'unzip', '-q', '-o', '/a.zip', '-d', '/dest' }, fs.extract_cmd('/a.zip', '/dest', 'zip'))
end)
t.it('uses tar for gzipped tarballs', function()
t.eq({ 'tar', '-xzf', '/a.tar.gz', '-C', '/dest' }, fs.extract_cmd('/a.tar.gz', '/dest', 'tar.gz'))
end)
end)
t.describe('fs.rm_rf', function()
t.it('removes a populated directory tree', function()
local dir = tmpdir()
vim.fn.mkdir(dir .. '/a/b', 'p')
write(dir .. '/a/b/c.txt', 'x')
fs.rm_rf(dir .. '/a')
t.falsy(fs.exists(dir .. '/a'))
end)
t.it('is a no-op on a path that is not there', function()
fs.rm_rf(tmpdir() .. '/never-existed')
end)
end)
+59
View File
@@ -0,0 +1,59 @@
local M = { passed = 0, failed = 0, failures = {} }
local current = '?'
function M.describe(name, fn)
current = name
fn()
end
function M.it(name, fn)
local ok, err = pcall(fn)
if ok then
M.passed = M.passed + 1
else
M.failed = M.failed + 1
table.insert(M.failures, string.format('%s > %s\n %s', current, name, tostring(err)))
end
end
local function render(v)
if type(v) == 'table' then
return vim.inspect(v)
end
return string.format('%q', tostring(v))
end
function M.eq(expected, actual, msg)
if not vim.deep_equal(expected, actual) then
error(string.format('%sexpected %s, got %s', msg and (msg .. ': ') or '', render(expected), render(actual)), 2)
end
end
function M.truthy(value, msg)
if not value then
error((msg or 'expected a truthy value') .. ', got ' .. render(value), 2)
end
end
function M.falsy(value, msg)
if value then
error((msg or 'expected a falsy value') .. ', got ' .. render(value), 2)
end
end
function M.matches(pattern, actual, msg)
if type(actual) ~= 'string' or not actual:find(pattern) then
error(string.format('%sexpected %s to match %q', msg and (msg .. ': ') or '', render(actual), pattern), 2)
end
end
function M.report()
for _, failure in ipairs(M.failures) do
io.stderr:write('FAIL ' .. failure .. '\n')
end
io.stdout:write(string.format('\n%d passed, %d failed\n', M.passed, M.failed))
return M.failed == 0
end
return M
+204
View File
@@ -0,0 +1,204 @@
local t = require('tests.harness')
local fs = require('arti-pack.fs')
local installer = require('arti-pack.installer')
local Lock = require('arti-pack.lock')
local spec_mod = require('arti-pack.spec')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'wb'))
fd:write(contents)
fd:close()
return path
end
local function have(cmd)
return vim.fn.executable(cmd) == 1
end
-- Build a tag archive that looks like the real thing: everything wrapped in a
-- single "<repo>-<version>" directory.
local function build_archive(ext, repo, version)
local work = tmpdir()
local root = string.format('%s-%s', repo, version)
vim.fn.mkdir(work .. '/' .. root .. '/lua', 'p')
vim.fn.mkdir(work .. '/' .. root .. '/doc', 'p')
write(work .. '/' .. root .. '/lua/marker.lua', 'return "' .. version .. '"')
write(work .. '/' .. root .. '/doc/thing.txt', '*thing.txt*\tA help tag\n')
local archive = string.format('%s/%s.%s', tmpdir(), root, ext)
local cmd
if ext == 'zip' then
cmd = { 'zip', '-q', '-r', archive, root }
else
cmd = { 'tar', '-czf', archive, root }
end
local out = vim.system(cmd, { cwd = work, text = true }):wait()
assert(out.code == 0, 'failed to build fixture archive: ' .. tostring(out.stderr))
return archive
end
local function new_ctx()
local base = tmpdir()
return {
registry = 'file://unused',
pack_dir = fs.mkdirp(base .. '/pack/arti-pack'),
cache_dir = fs.mkdirp(base .. '/cache'),
concurrency = 2,
timeout = 30,
lock = Lock.new(base .. '/lock.json'),
}
end
local function run(ctx, specs)
local results
installer.run(ctx, specs, function(r)
results = r
end)
assert(vim.wait(30000, function()
return results ~= nil
end, 20), 'installer.run never called back')
return results
end
local function spec_for(source, tag, archive, extra)
local entry = vim.tbl_extend('force', { source, tag = tag, url = 'file://' .. archive }, extra or {})
return assert(spec_mod.parse(entry))
end
t.describe('installer.run', function()
if not have('zip') or not have('unzip') then
t.it('SKIPPED: zip/unzip unavailable', function() end)
return
end
t.it('installs a zip into pack/start and strips the wrapper directory', function()
local archive = build_archive('zip', 'plenary.nvim', '0.1.4')
local ctx = new_ctx()
local results = run(ctx, { spec_for('nvim-lua/plenary.nvim', 'v0.1.4', archive) })
t.eq(1, #results)
t.eq('installed', results[1].status, results[1].err)
local dir = ctx.pack_dir .. '/start/plenary.nvim'
t.truthy(fs.exists(dir .. '/lua/marker.lua'), 'plugin files land at the top level')
t.falsy(fs.exists(dir .. '/plenary.nvim-0.1.4'), 'wrapper directory is stripped')
t.eq('v0.1.4', ctx.lock:get('plenary.nvim').tag)
end)
t.it('installs into pack/opt when opt is set', function()
local archive = build_archive('zip', 'lazy.nvim', '1.0.0')
local ctx = new_ctx()
local results = run(ctx, { spec_for('folke/lazy.nvim', 'v1.0.0', archive, { opt = true }) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/opt/lazy.nvim/lua/marker.lua'))
t.falsy(fs.exists(ctx.pack_dir .. '/start/lazy.nvim'))
t.eq(true, ctx.lock:get('lazy.nvim').opt)
end)
t.it('installs a tar.gz the same way', function()
local archive = build_archive('tar.gz', 'thing.nvim', '2.0.0')
local ctx = new_ctx()
local results = run(ctx, { spec_for('owner/thing.nvim', 'v2.0.0', archive, { ext = 'tar.gz' }) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/thing.nvim/lua/marker.lua'))
end)
t.it('replaces the old tree on a tag change and reports an update', function()
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', build_archive('zip', 'thing.nvim', '1.0.0')) })
write(ctx.pack_dir .. '/start/thing.nvim/leftover.txt', 'stale')
local results = run(ctx, { spec_for('owner/thing.nvim', 'v2.0.0', build_archive('zip', 'thing.nvim', '2.0.0')) })
t.eq('updated', results[1].status, results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/thing.nvim/leftover.txt'), 'stale files are cleared')
t.eq('v2.0.0', ctx.lock:get('thing.nvim').tag)
end)
t.it('moves a plugin between start and opt without leaving a copy behind', function()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', archive) })
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', archive, { opt = true }) })
t.truthy(fs.exists(ctx.pack_dir .. '/opt/thing.nvim'))
t.falsy(fs.exists(ctx.pack_dir .. '/start/thing.nvim'))
end)
t.it('fails one plugin without abandoning the rest of the run', function()
local ctx = new_ctx()
local results = run(ctx, {
spec_for('owner/good', 'v1.0.0', build_archive('zip', 'good', '1.0.0')),
spec_for('owner/missing', 'v1.0.0', tmpdir() .. '/does-not-exist.zip'),
spec_for('owner/alsogood', 'v1.0.0', build_archive('zip', 'alsogood', '1.0.0')),
})
local byname = {}
for _, r in ipairs(results) do
byname[r.name] = r
end
t.eq(3, #results)
t.eq('installed', byname.good.status)
t.eq('installed', byname.alsogood.status)
t.eq('failed', byname.missing.status)
t.matches('download failed', byname.missing.err)
t.falsy(ctx.lock:get('missing'), 'a failed plugin is not recorded')
end)
t.it('rejects a download that is not an archive and leaves nothing behind', function()
local ctx = new_ctx()
local bogus = write(tmpdir() .. '/bogus.zip', '<html>404 not found</html>')
local results = run(ctx, { spec_for('owner/bogus', 'v1.0.0', bogus) })
t.eq('failed', results[1].status)
t.matches('not a zip', results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/bogus'))
end)
t.it('reuses a cached archive instead of downloading again', function()
local ctx = new_ctx()
local archive = build_archive('zip', 'cached.nvim', '1.0.0')
run(ctx, { spec_for('owner/cached.nvim', 'v1.0.0', archive) })
t.truthy(fs.exists(ctx.cache_dir .. '/cached.nvim-v1.0.0.zip'), 'archive is cached by name and tag')
-- Delete the source the url points at: a second run can only succeed from cache.
vim.fn.delete(archive)
fs.rm_rf(ctx.pack_dir .. '/start/cached.nvim')
local results = run(ctx, { spec_for('owner/cached.nvim', 'v1.0.0', archive) })
t.eq('updated', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/cached.nvim/lua/marker.lua'))
end)
t.it('installs several plugins concurrently', function()
local ctx = new_ctx()
local specs = {}
for i = 1, 5 do
local name = 'plug' .. i
table.insert(specs, spec_for('owner/' .. name, 'v1.0.0', build_archive('zip', name, '1.0.0')))
end
local results = run(ctx, specs)
t.eq(5, #results)
for _, r in ipairs(results) do
t.eq('installed', r.status, r.err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/' .. r.name .. '/lua/marker.lua'))
end
end)
t.it('returns immediately for an empty spec list', function()
t.eq({}, run(new_ctx(), {}))
end)
t.it('leaves no staging directory behind', function()
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', build_archive('zip', 'thing.nvim', '1.0.0')) })
t.eq({}, fs.entries(ctx.pack_dir .. '/.stage'))
end)
end)
+91
View File
@@ -0,0 +1,91 @@
local t = require('tests.harness')
local Lock = require('arti-pack.lock')
local spec = require('arti-pack.spec')
local function tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
local function write(path, contents)
local fd = assert(io.open(path, 'w'))
fd:write(contents)
fd:close()
end
t.describe('lock', function()
t.it('round-trips entries through the state file', function()
local path = tmpdir() .. '/nested/lock.json'
local lock = Lock.new(path)
lock:set('plenary.nvim', { tag = 'v0.1.4', opt = false })
t.truthy(lock:save())
local reloaded = Lock.new(path):load()
t.eq({ tag = 'v0.1.4', opt = false }, reloaded:get('plenary.nvim'))
end)
t.it('starts empty when the state file does not exist', function()
local lock = Lock.new(tmpdir() .. '/absent.json'):load()
t.eq({}, lock:names())
end)
t.it('starts empty when the state file is corrupt', function()
local path = tmpdir() .. '/lock.json'
write(path, 'not json at all')
t.eq({}, Lock.new(path):load():names())
end)
t.it('starts empty when the state file has no plugins table', function()
local path = tmpdir() .. '/lock.json'
write(path, '{"version":1}')
t.eq({}, Lock.new(path):load():names())
end)
t.it('lists names in sorted order', function()
local lock = Lock.new(tmpdir() .. '/lock.json')
lock:set('zebra', { tag = 'v1' })
lock:set('alpha', { tag = 'v1' })
t.eq({ 'alpha', 'zebra' }, lock:names())
end)
t.it('drops removed entries from a saved file', function()
local path = tmpdir() .. '/lock.json'
local lock = Lock.new(path)
lock:set('gone', { tag = 'v1' })
lock:set('kept', { tag = 'v1' })
lock:remove('gone')
lock:save()
t.eq({ 'kept' }, Lock.new(path):load():names())
end)
end)
t.describe('lock.is_current', function()
local function make(entry)
local lock = Lock.new(tmpdir() .. '/lock.json')
if entry then
lock:set('repo', entry)
end
return lock
end
t.it('is false for a plugin that was never installed', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
t.falsy(make(nil):is_current(s))
end)
t.it('is true when the tag and load style both match', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
t.truthy(make({ tag = 'v1', opt = false }):is_current(s))
end)
t.it('is false when the tag changed', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v2' }))
t.falsy(make({ tag = 'v1', opt = false }):is_current(s))
end)
t.it('is false when the plugin moved between start and opt', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', opt = true }))
t.falsy(make({ tag = 'v1', opt = false }):is_current(s))
end)
end)
+15
View File
@@ -0,0 +1,15 @@
local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h')
package.path = table.concat({
root .. '/lua/?.lua',
root .. '/lua/?/init.lua',
root .. '/?.lua',
package.path,
}, ';')
local t = require('tests.harness')
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec' }) do
require('tests.' .. name)
end
os.exit(t.report() and 0 or 1)
+136
View File
@@ -0,0 +1,136 @@
local t = require('tests.harness')
local spec = require('arti-pack.spec')
local REGISTRY = 'https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github'
t.describe('spec.parse', function()
t.it('parses a full table spec', function()
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4', opt = true }))
t.eq('nvim-lua', s.owner)
t.eq('plenary.nvim', s.repo)
t.eq('plenary.nvim', s.name)
t.eq('v0.1.4', s.tag)
t.eq('zip', s.ext)
t.eq(true, s.opt)
end)
t.it('defaults opt to false', function()
local s = assert(spec.parse({ 'tpope/vim-fugitive', tag = 'v3.7' }))
t.eq(false, s.opt)
end)
t.it('honours an "as" override for the install directory', function()
local s = assert(spec.parse({ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' }))
t.eq('tokyonight', s.name)
end)
t.it('accepts a source key instead of the positional entry', function()
local s = assert(spec.parse({ source = 'owner/repo', tag = 'v1' }))
t.eq('owner/repo', s.source)
end)
t.it('rejects a bare string with no tag', function()
local s, err = spec.parse('owner/repo')
t.falsy(s)
t.matches('missing a tag', err)
end)
t.it('rejects a source without an owner', function()
local s, err = spec.parse({ 'plenary.nvim', tag = 'v1' })
t.falsy(s)
t.matches('expected "owner/repo"', err)
end)
t.it('rejects a source with extra path segments', function()
local s, err = spec.parse({ 'a/b/c', tag = 'v1' })
t.falsy(s)
t.matches('expected "owner/repo"', err)
end)
t.it('rejects a tag containing a path separator', function()
local s, err = spec.parse({ 'owner/repo', tag = '../../etc' })
t.falsy(s)
t.matches('invalid tag', err)
end)
t.it('rejects an unsupported extension', function()
local s, err = spec.parse({ 'owner/repo', tag = 'v1', ext = 'rar' })
t.falsy(s)
t.matches('unsupported ext', err)
end)
t.it('rejects a traversing "as" name', function()
local s, err = spec.parse({ 'owner/repo', tag = 'v1', as = '..' })
t.falsy(s)
t.matches('unusable directory name', err)
end)
t.it('rejects a non-table, non-string spec', function()
local s, err = spec.parse(42)
t.falsy(s)
t.matches('must be a string or a table', err)
end)
t.it('trims surrounding whitespace from the source and tag', function()
local s = assert(spec.parse({ ' owner/repo ', tag = ' v1.0.0 ' }))
t.eq('owner/repo', s.source)
t.eq('v1.0.0', s.tag)
end)
end)
t.describe('spec.url', function()
t.it('builds a tag archive url', function()
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' }))
t.eq(REGISTRY .. '/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip', spec.url(s, REGISTRY))
end)
t.it('honours the tar.gz extension', function()
local s = assert(spec.parse({ 'nvim-lua/plenary.nvim', tag = 'v0.1.4', ext = 'tar.gz' }))
t.matches('/v0%.1%.4%.tar%.gz$', spec.url(s, REGISTRY))
end)
t.it('does not double up on a trailing registry slash', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
t.eq(REGISTRY .. '/owner/repo/archive/refs/tags/v1.zip', spec.url(s, REGISTRY .. '///'))
end)
t.it('passes an explicit url straight through', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = 'https://example.test/a.zip' }))
t.eq('https://example.test/a.zip', spec.url(s, REGISTRY))
end)
t.it('errors when no registry is configured', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
local url, err = spec.url(s, '')
t.falsy(url)
t.matches('registry', err)
end)
end)
t.describe('spec.parse_all', function()
t.it('collects specs and reports per-entry errors', function()
local specs, errors = spec.parse_all({
{ 'a/one', tag = 'v1' },
{ 'bad-source', tag = 'v1' },
{ 'b/two', tag = 'v2', opt = true },
})
t.eq(2, #specs)
t.eq(1, #errors)
t.matches('plugins%[2%]', errors[1])
end)
t.it('rejects two specs that install to the same directory', function()
local specs, errors = spec.parse_all({
{ 'a/shared', tag = 'v1' },
{ 'b/shared', tag = 'v2' },
})
t.eq(1, #specs)
t.matches('duplicate plugin name', errors[1])
end)
t.it('returns empty results for a nil plugin list', function()
local specs, errors = spec.parse_all(nil)
t.eq({}, specs)
t.eq({}, errors)
end)
end)