Skip to content

Commit 2a65d8f

Browse files
committed
feat: download prebuilt binary in build step, fall back to source
Add lua/lockfile/download.lua for use as a plugin-manager build step. It detects the platform target triple and, when HEAD is at a release tag, downloads the matching prebuilt binary from the GitHub release, validates it with package.loadlib, and atomically installs it. When no matching prebuilt exists (branch installs, unsupported platforms) or the download fails, it builds from source with cargo. Update install docs to use the download step.
1 parent b31759c commit 2a65d8f

3 files changed

Lines changed: 257 additions & 15 deletions

File tree

README.md

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,37 +68,52 @@ which commit (these are not classified as semver bumps).
6868
## Requirements
6969

7070
- Neovim 0.10+ (uses `vim.system`, `vim.fs`, extmark highlights).
71-
- A **Rust toolchain** (`cargo`) to build the native module. All parsing and git
72-
access is implemented in Rust (via [`mlua`](https://github.com/mlua-rs/mlua),
73-
[`nom`](https://github.com/rust-bakery/nom), and
74-
[`git2`](https://github.com/rust-lang/git2-rs)); the compiled module is a hard
75-
dependency.
71+
- The native module (a hard dependency). All parsing, version comparison, and
72+
git access are implemented in Rust (via [`mlua`](https://github.com/mlua-rs/mlua),
73+
[`nom`](https://github.com/rust-bakery/nom),
74+
[`git2`](https://github.com/rust-lang/git2-rs),
75+
[`semver`](https://github.com/dtolnay/semver), and
76+
[`pep440_rs`](https://github.com/konstin/pep440-rs)). When you install a
77+
**tagged release**, a prebuilt binary is downloaded for your platform; otherwise
78+
the build step compiles from source, which needs a **Rust toolchain** (`cargo`).
79+
80+
Prebuilt binaries are published for Linux (x86_64, aarch64) and macOS (x86_64,
81+
aarch64). Other platforms (and untagged/branch installs) build from source.
7682

7783
## Installation
7884

79-
The plugin ships Rust source that must be compiled into a native module. Run
80-
`make` in the plugin directory after install.
85+
The build step downloads a prebuilt native module for the installed release tag,
86+
falling back to compiling from source (requires `cargo`).
8187

8288
### lazy.nvim
8389

8490
```lua
8591
{
86-
"yourname/lockfile.nvim",
87-
build = "make",
92+
"willothy/lockfile.nvim",
93+
build = function()
94+
require("lockfile.download").download_or_build()
95+
end,
8896
opts = {},
8997
}
9098
```
9199

100+
Pin to a release tag (e.g. `version = "*"` or `tag = "v1.0.0"`) to get a prebuilt
101+
binary; tracking a branch always builds from source.
102+
92103
### packer.nvim
93104

94105
```lua
95-
use({ "yourname/lockfile.nvim", run = "make", config = function() require("lockfile").setup() end })
106+
use({
107+
"willothy/lockfile.nvim",
108+
run = function() require("lockfile.download").download_or_build() end,
109+
config = function() require("lockfile").setup() end,
110+
})
96111
```
97112

98113
### Manual
99114

100115
```sh
101-
git clone https://github.com/yourname/lockfile.nvim
116+
git clone https://github.com/willothy/lockfile.nvim
102117
cd lockfile.nvim
103118
make # builds lua/lockfile_native.so via cargo
104119
```

doc/lockfile.txt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@ Supported lockfiles:
3434
2. REQUIREMENTS *lockfile-requirements*
3535

3636
- Neovim 0.10 or newer.
37-
- A Rust toolchain (`cargo`). All parsing and git access is implemented in a
38-
Rust native module loaded via mlua; it must be compiled with `make` (or
39-
`cargo build --release`) before use. It is a hard dependency: if it is not
40-
built, commands report an error with build instructions.
37+
- The native module (a hard dependency), implemented in Rust and loaded via
38+
mlua. The build step |lockfile.download()| downloads a prebuilt binary when
39+
HEAD is at a tagged release, otherwise it builds from source, which requires a
40+
Rust toolchain (`cargo`). If the module is missing, commands report an error
41+
with build instructions.
42+
43+
Prebuilt binaries are published for Linux (x86_64, aarch64) and macOS
44+
(x86_64, aarch64); other platforms build from source.
4145

4246
==============================================================================
4347
3. USAGE *lockfile-usage*
@@ -143,4 +147,9 @@ lockfile.build_report({path}, {opts}) *lockfile.build_report()*
143147
Build an analyzed report without opening a window. Returns
144148
`report, labels` or `nil, nil, err`. Useful for custom UIs.
145149

150+
require("lockfile.download").download_or_build() *lockfile.download()*
151+
Install the native module: download a prebuilt binary matching the
152+
installed release tag, or build from source. Intended for a plugin
153+
manager's build step.
154+
146155
vim:tw=78:ts=8:ft=help:norl:

lua/lockfile/download.lua

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
--- Install the native module for a plugin-manager build step: download a
2+
--- prebuilt binary matching the installed release tag, and fall back to building
3+
--- from source when no matching prebuilt exists (e.g. tracking a branch) or the
4+
--- download fails.
5+
---
6+
--- Use as a lazy.nvim build step:
7+
--- build = function() require("lockfile.download").download_or_build() end
8+
---
9+
--- Prebuilt binaries are published per release tag (see .github/workflows). A
10+
--- prebuilt is only used when the checked-out HEAD is exactly at a release tag,
11+
--- so the binary always matches the source it ships with; otherwise the module
12+
--- is built from source.
13+
14+
local M = {}
15+
16+
--- GitHub "owner/repo" that publishes the release binaries.
17+
local REPO = "willothy/lockfile.nvim"
18+
19+
--- Absolute path to the plugin root (the directory containing `lua/`).
20+
---@return string
21+
local function plugin_root()
22+
-- download.lua -> lockfile/ -> lua/ -> <root>
23+
return vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h")
24+
end
25+
26+
--- Whether the host is Windows.
27+
---@return boolean
28+
local function is_windows()
29+
return vim.fn.has("win32") == 1
30+
end
31+
32+
--- Extension of the loadable module file on this platform ("so" | "dll").
33+
---@return string
34+
local function lib_extension()
35+
return is_windows() and "dll" or "so"
36+
end
37+
38+
--- The Rust target triple for the current platform, or nil if unsupported.
39+
---@return string?
40+
local function target_triple()
41+
local uname = vim.uv.os_uname()
42+
local sysname = uname.sysname:lower()
43+
local machine = uname.machine:lower()
44+
45+
local arch
46+
if machine == "x86_64" or machine == "amd64" then
47+
arch = "x86_64"
48+
elseif machine == "aarch64" or machine == "arm64" then
49+
arch = "aarch64"
50+
else
51+
return nil
52+
end
53+
54+
if sysname:find("darwin", 1, true) then
55+
return arch .. "-apple-darwin"
56+
elseif sysname:find("windows", 1, true) or sysname:find("mingw", 1, true) then
57+
return arch .. "-pc-windows-msvc"
58+
elseif sysname:find("linux", 1, true) then
59+
-- Distinguish musl from glibc; the binaries are not interchangeable.
60+
local libc = "gnu"
61+
local ldd = vim.system({ "ldd", "--version" }):wait()
62+
local out = ((ldd.stdout or "") .. (ldd.stderr or "")):lower()
63+
if out:find("musl", 1, true) then
64+
libc = "musl"
65+
end
66+
return arch .. "-unknown-linux-" .. libc
67+
end
68+
return nil
69+
end
70+
71+
--- Path where the loadable module must live for `native.lua` to find it.
72+
---@param root string
73+
---@return string
74+
local function installed_path(root)
75+
return root .. "/lua/lockfile_native." .. lib_extension()
76+
end
77+
78+
--- Path of the freshly built library for a host `cargo build --release`.
79+
---@param root string
80+
---@return string
81+
local function built_path(root)
82+
local base = root .. "/target/release/"
83+
if is_windows() then
84+
return base .. "lockfile_native.dll"
85+
elseif vim.uv.os_uname().sysname:lower():find("darwin", 1, true) then
86+
return base .. "liblockfile_native.dylib"
87+
end
88+
return base .. "liblockfile_native.so"
89+
end
90+
91+
--- The release tag at the current HEAD, or nil if HEAD is not exactly at a tag.
92+
---@param root string
93+
---@return string?
94+
local function release_tag(root)
95+
local res = vim.system({ "git", "-C", root, "describe", "--tags", "--exact-match", "HEAD" }):wait()
96+
if res.code == 0 then
97+
local tag = vim.trim(res.stdout or "")
98+
if tag ~= "" then
99+
return tag
100+
end
101+
end
102+
return nil
103+
end
104+
105+
--- Download `url` to `out` with curl. Returns ok, error message.
106+
---@param url string
107+
---@param out string
108+
---@return boolean ok
109+
---@return string? err
110+
local function curl(url, out)
111+
if vim.fn.executable("curl") == 0 then
112+
return false, "curl is not available"
113+
end
114+
local res = vim.system({
115+
"curl",
116+
"--fail",
117+
"--location",
118+
"--silent",
119+
"--show-error",
120+
"--output",
121+
out,
122+
url,
123+
}):wait()
124+
if res.code ~= 0 then
125+
return false, vim.trim(res.stderr or "download failed")
126+
end
127+
return true, nil
128+
end
129+
130+
--- Try to download and install a prebuilt binary for `tag`. Returns ok, err.
131+
---@param root string
132+
---@param tag string
133+
---@param triple string
134+
---@return boolean ok
135+
---@return string? err
136+
local function install_prebuilt(root, tag, triple)
137+
local asset = ("lockfile_native-%s.%s"):format(triple, lib_extension())
138+
local url = ("https://github.com/%s/releases/download/%s/%s"):format(REPO, tag, asset)
139+
local dest = installed_path(root)
140+
local tmp = dest .. ".tmp"
141+
142+
local ok, err = curl(url, tmp)
143+
if not ok then
144+
return false, err
145+
end
146+
147+
-- Validate the download actually loads before swapping it in.
148+
local loader = package.loadlib(tmp, "luaopen_lockfile_native")
149+
if not loader then
150+
pcall(vim.uv.fs_unlink, tmp)
151+
return false, "downloaded binary failed to load"
152+
end
153+
154+
local renamed, rename_err = vim.uv.fs_rename(tmp, dest)
155+
if not renamed then
156+
if is_windows() then
157+
-- The live DLL may be locked by this session; leave the validated .tmp
158+
-- for the next restart to promote.
159+
vim.notify(
160+
"lockfile.nvim: downloaded binary saved to " .. tmp .. "; restart Neovim to apply.",
161+
vim.log.levels.WARN
162+
)
163+
return true, nil
164+
end
165+
pcall(vim.uv.fs_unlink, tmp)
166+
return false, "failed to install binary: " .. tostring(rename_err)
167+
end
168+
return true, nil
169+
end
170+
171+
--- Build the native module from source and copy it into place.
172+
---@param root string?
173+
function M.build(root)
174+
root = root or plugin_root()
175+
if vim.fn.executable("cargo") == 0 then
176+
error(
177+
"lockfile.nvim: no prebuilt binary for this platform and `cargo` was not found "
178+
.. "to build from source. Install Rust from https://rustup.rs/",
179+
0
180+
)
181+
end
182+
183+
local res = vim.system({ "cargo", "build", "--release" }, { cwd = root }):wait()
184+
if res.code ~= 0 then
185+
error("lockfile.nvim: `cargo build --release` failed:\n" .. (res.stderr or ""), 0)
186+
end
187+
188+
local dest = installed_path(root)
189+
local ok, err = vim.uv.fs_copyfile(built_path(root), dest)
190+
if not ok then
191+
error("lockfile.nvim: failed to copy built binary to " .. dest .. ": " .. tostring(err), 0)
192+
end
193+
vim.notify("lockfile.nvim: built native module from source.")
194+
end
195+
196+
--- Install the native module: prefer a prebuilt binary matching the installed
197+
--- release tag, otherwise build from source.
198+
function M.download_or_build()
199+
local root = plugin_root()
200+
local tag = release_tag(root)
201+
local triple = target_triple()
202+
203+
if tag and triple then
204+
local ok, err = install_prebuilt(root, tag, triple)
205+
if ok then
206+
vim.notify(("lockfile.nvim: installed prebuilt binary for %s (%s)."):format(triple, tag))
207+
return
208+
end
209+
vim.notify(
210+
("lockfile.nvim: prebuilt unavailable (%s); building from source."):format(err or "unknown"),
211+
vim.log.levels.INFO
212+
)
213+
end
214+
215+
M.build(root)
216+
end
217+
218+
return M

0 commit comments

Comments
 (0)