From 46517953e53b665e1ea83640543323f9ca4e63f4 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 8 Sep 2026 23:22:15 +1000 Subject: [PATCH] Add arti-pack plugin manager scaffold - 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 --- .editorconfig | 11 ++ .gitignore | 1 + .luacheckrc | 3 + .pre-commit-config.yaml | 10 ++ .stylua.toml | 4 + .woodpecker/lint.yaml | 19 ++++ .woodpecker/pre-commit.yaml | 18 +++ .woodpecker/test.yaml | 19 ++++ Makefile | 36 ++++++ README.md | 97 +++++++++++++++- doc/arti-pack.txt | 82 ++++++++++++++ lua/arti-pack/fs.lua | 88 +++++++++++++++ lua/arti-pack/init.lua | 220 ++++++++++++++++++++++++++++++++++++ lua/arti-pack/installer.lua | 165 +++++++++++++++++++++++++++ lua/arti-pack/lock.lua | 79 +++++++++++++ lua/arti-pack/spec.lua | 113 ++++++++++++++++++ plugin/arti-pack.lua | 20 ++++ tests/fs_spec.lua | 99 ++++++++++++++++ tests/harness.lua | 59 ++++++++++ tests/installer_spec.lua | 204 +++++++++++++++++++++++++++++++++ tests/lock_spec.lua | 91 +++++++++++++++ tests/run.lua | 15 +++ tests/spec_spec.lua | 136 ++++++++++++++++++++++ 23 files changed, 1588 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .luacheckrc create mode 100644 .pre-commit-config.yaml create mode 100644 .stylua.toml create mode 100644 .woodpecker/lint.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Makefile create mode 100644 doc/arti-pack.txt create mode 100644 lua/arti-pack/fs.lua create mode 100644 lua/arti-pack/init.lua create mode 100644 lua/arti-pack/installer.lua create mode 100644 lua/arti-pack/lock.lua create mode 100644 lua/arti-pack/spec.lua create mode 100644 plugin/arti-pack.lua create mode 100644 tests/fs_spec.lua create mode 100644 tests/harness.lua create mode 100644 tests/installer_spec.lua create mode 100644 tests/lock_spec.lua create mode 100644 tests/run.lua create mode 100644 tests/spec_spec.lua diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ef93195 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..926ccaa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +doc/tags diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..39af3f7 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,3 @@ +std = "lua51" +globals = { "vim" } +max_line_length = 120 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7ff64ea --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..6272367 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,4 @@ +column_width = 120 +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferSingle" diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml new file mode 100644 index 0000000..92c873c --- /dev/null +++ b/.woodpecker/lint.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..a2f7f95 --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3d44e3 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 5521c85..2b3e58c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,98 @@ # arti-pack -Neovim plugin manager that installs plugins from tagged archives proxied through artifactapi into nvim's native package path \ No newline at end of file +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/`, or `.../opt/` 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 + +``` +///archive/refs/tags/. +``` + +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 `-` +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 +``` diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt new file mode 100644 index 0000000..e6fa6b0 --- /dev/null +++ b/doc/arti-pack.txt @@ -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: > + ///archive/refs/tags/. +< +============================================================================== +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/ loaded at startup + stdpath('data')/site/pack/arti-pack/opt/ 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 "-" 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: diff --git a/lua/arti-pack/fs.lua b/lua/arti-pack/fs.lua new file mode 100644 index 0000000..f3834cd --- /dev/null +++ b/lua/arti-pack/fs.lua @@ -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 "-" 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 diff --git a/lua/arti-pack/init.lua b/lua/arti-pack/init.lua new file mode 100644 index 0000000..4b09a78 --- /dev/null +++ b/lua/arti-pack/init.lua @@ -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 diff --git a/lua/arti-pack/installer.lua b/lua/arti-pack/installer.lua new file mode 100644 index 0000000..26b4c78 --- /dev/null +++ b/lua/arti-pack/installer.lua @@ -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 diff --git a/lua/arti-pack/lock.lua b/lua/arti-pack/lock.lua new file mode 100644 index 0000000..fdea743 --- /dev/null +++ b/lua/arti-pack/lock.lua @@ -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 diff --git a/lua/arti-pack/spec.lua b/lua/arti-pack/spec.lua new file mode 100644 index 0000000..71b425b --- /dev/null +++ b/lua/arti-pack/spec.lua @@ -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 "/". +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 diff --git a/plugin/arti-pack.lua b/plugin/arti-pack.lua new file mode 100644 index 0000000..d7dd5de --- /dev/null +++ b/plugin/arti-pack.lua @@ -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' }) diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua new file mode 100644 index 0000000..98ad6d9 --- /dev/null +++ b/tests/fs_spec.lua @@ -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', '404') + 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) diff --git a/tests/harness.lua b/tests/harness.lua new file mode 100644 index 0000000..ba0e8f0 --- /dev/null +++ b/tests/harness.lua @@ -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 diff --git a/tests/installer_spec.lua b/tests/installer_spec.lua new file mode 100644 index 0000000..330612d --- /dev/null +++ b/tests/installer_spec.lua @@ -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 "-" 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', '404 not found') + 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) diff --git a/tests/lock_spec.lua b/tests/lock_spec.lua new file mode 100644 index 0000000..58d97e8 --- /dev/null +++ b/tests/lock_spec.lua @@ -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) diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..a0bb7b5 --- /dev/null +++ b/tests/run.lua @@ -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) diff --git a/tests/spec_spec.lua b/tests/spec_spec.lua new file mode 100644 index 0000000..eca0019 --- /dev/null +++ b/tests/spec_spec.lua @@ -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)