diff --git a/README.md b/README.md index e7607af..4dccef3 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ a Rust toolchain (1.83+ stable). You can run install manually any time with `:NuwikiInstall` (works in both Vim and Neovim) instead of relying on a plugin-manager build hook. +On Linux the installer picks the musl (statically linked) asset when the +host is musl-based or is not an FHS distribution — NixOS and Guix System +keep glibc in the store, so a generic `-gnu` binary cannot find its dynamic +loader there. Elsewhere it prefers the `-gnu` asset and falls back to musl +if the downloaded binary turns out not to run. Set `g:nuwiki_ls_target` to +a release triple (e.g. `x86_64-unknown-linux-musl`) to force the choice. + ### Requirements - **Neovim 0.11+** — uses the built-in LSP client; no extra plugin. @@ -462,6 +469,7 @@ For users configuring without Lua. | `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup | | `g:nuwiki_mouse_mappings` | `0` | `1` enables mouse maps | | `g:nuwiki_binary_path` | _(auto)_ | path to a prebuilt `nuwiki-ls` binary, bypassing the bundled one | +| `g:nuwiki_ls_target` | _(auto)_ | force a release target triple at install time, e.g. `x86_64-unknown-linux-musl` | | `g:nuwiki_no_calendar` | `0` | `1` opts out of calendar-vim integration | | `g:nuwiki_auto_chdir` | `0` | `1` `:lcd` into the wiki root when a wiki buffer becomes current | | `g:nuwiki_auto_header` | `0` | `1` insert a level-1 header from the filename on new wiki pages | diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index 6120334..16540ea 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -62,6 +62,14 @@ Highlights: Install via your plugin manager; the build hook downloads a pre-built `nuwiki-ls` binary into the plugin's `bin/` directory. +On Linux the installer picks the musl (statically linked) asset when the +host is musl-based or is not an FHS distribution — NixOS and Guix System +keep glibc in the store, so a generic `-gnu` binary cannot find its dynamic +loader there. Elsewhere it prefers the `-gnu` asset and falls back to musl +if the downloaded binary turns out not to run. Set `g:nuwiki_ls_target` + *g:nuwiki_ls_target* +to a release triple (e.g. `x86_64-unknown-linux-musl`) to force the choice. + lazy.nvim: > { diff --git a/lua/nuwiki/install.lua b/lua/nuwiki/install.lua index 88c0f00..d1b16e8 100644 --- a/lua/nuwiki/install.lua +++ b/lua/nuwiki/install.lua @@ -3,8 +3,11 @@ -- Strategy: -- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin. -- 2. If missing, download a release asset for the current target from --- nuwiki-rs. +-- nuwiki-rs, trying each candidate triple in turn and rejecting one the +-- host cannot actually exec. -- 3. On failure, fall back to cloning nuwiki-rs and building with cargo. +-- +-- Set `vim.g.nuwiki_ls_target` to force a specific release triple. local M = {} @@ -25,23 +28,80 @@ function M.is_installed() return vim.fn.executable(M.expected_path()) == 1 end -local function target_triple() +local function is_musl(arch) + local uv = vim.uv or vim.loop + if uv.fs_stat('/lib/ld-musl-' .. arch .. '.so.1') then + return true + end + if vim.fn.executable('ldd') ~= 1 then + return false + end + -- musl's ldd writes its banner to stderr. + return vim.fn.system('ldd --version 2>&1'):lower():find('musl', 1, true) ~= nil +end + +-- NixOS and Guix System are not FHS distributions: glibc lives in the store, +-- so the `/lib64/ld-linux-*.so.2` a generic `-gnu` binary is linked against is +-- either absent or — on NixOS — a stub that only prints an error and exits +-- 127. `ldd --version` still reports GNU libc there, so libc detection alone +-- picks the wrong asset. The musl builds are statically linked and need no +-- loader at all, so they run on these systems unchanged. +local function is_non_fhs_linux() + local uv = vim.uv or vim.loop + if uv.fs_stat('/etc/NIXOS') then + return true + end + local ok, lines = pcall(vim.fn.readfile, '/etc/os-release') + if not ok then + return false + end + for _, line in ipairs(lines) do + local id = line:match('^ID=%s*"?([%w%-%._]+)') + if id == 'nixos' or id == 'guix' then + return true + end + end + return false +end + +-- Release triples to try, best first. More than one entry means the later +-- ones are fallbacks for a host that turns out not to be able to run the +-- first (see can_exec below). +function M.targets() + local override = vim.g.nuwiki_ls_target + if type(override) == 'string' and override ~= '' then + return { override } + end if vim.fn.has('win32') == 1 then - return 'x86_64-pc-windows-msvc' + return { 'x86_64-pc-windows-msvc' } elseif vim.fn.has('mac') == 1 then if vim.fn.has('arm64') == 1 or jit and jit.arch == 'arm64' then - return 'aarch64-apple-darwin' + return { 'aarch64-apple-darwin' } end - return 'x86_64-apple-darwin' + return { 'x86_64-apple-darwin' } elseif vim.fn.has('linux') == 1 then local uname = vim.uv and vim.uv.os_uname() or vim.loop.os_uname() - local musl = (vim.fn.system('ldd --version 2>&1'):find('musl') ~= nil) - if uname.machine == 'aarch64' or uname.machine == 'arm64' then - return musl and 'aarch64-unknown-linux-musl' or 'aarch64-unknown-linux-gnu' + local arch = (uname.machine == 'aarch64' or uname.machine == 'arm64') + and 'aarch64' or 'x86_64' + local musl = arch .. '-unknown-linux-musl' + if is_musl(arch) or is_non_fhs_linux() then + return { musl } end - return musl and 'x86_64-unknown-linux-musl' or 'x86_64-unknown-linux-gnu' + -- Static musl runs anywhere, so keep it as a last resort on glibc hosts + -- too (old glibc, exotic loader layouts). + return { arch .. '-unknown-linux-gnu', musl } end - return nil + return {} +end + +-- Can this host actually start the binary? Exit codes 126 and 127 mean the +-- kernel or the dynamic loader refused to exec it (missing/stub loader, wrong +-- architecture); any other code means it ran and exited on its own terms. +-- system() gives the child an empty stdin, so the LSP loop sees EOF and stops. +local function can_exec(path) + vim.fn.system({ path, '--version' }) + local code = vim.v.shell_error + return code ~= 126 and code ~= 127 end local function build_from_source(dest) @@ -80,11 +140,7 @@ local function build_from_source(dest) return vim.fn.executable(dest) == 1 end -local function download_release(dest) - local target = target_triple() - if not target then - return false - end +local function download_release(dest, target) if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then return false end @@ -103,7 +159,14 @@ local function download_release(dest) if vim.v.shell_error ~= 0 then return false end - vim.fn.system({ 'cp', extract .. '/nuwiki-ls' .. exe_suffix(), dest }) + -- Vet the staged copy before it becomes the installed one, so a binary this + -- host cannot exec never lands at `dest` for the LSP client to pick up. + local staged = extract .. '/nuwiki-ls' .. exe_suffix() + vim.fn.system({ 'chmod', '+x', staged }) + if vim.fn.executable(staged) ~= 1 or not can_exec(staged) then + return false + end + vim.fn.system({ 'cp', staged, dest }) vim.fn.system({ 'chmod', '+x', dest }) return vim.fn.executable(dest) == 1 end @@ -112,9 +175,12 @@ function M.install() local dest = M.expected_path() vim.fn.mkdir(vim.fn.fnamemodify(dest, ':h'), 'p') - if download_release(dest) then - vim.notify('nuwiki: installed ' .. dest, vim.log.levels.INFO) - return true + for _, target in ipairs(M.targets()) do + if download_release(dest, target) then + vim.notify('nuwiki: installed ' .. dest .. ' (' .. target .. ')', + vim.log.levels.INFO) + return true + end end vim.notify('nuwiki: download failed, falling back to source build', vim.log.levels.WARN) diff --git a/scripts/download_bin.vim b/scripts/download_bin.vim index fc3a467..c1310c5 100644 --- a/scripts/download_bin.vim +++ b/scripts/download_bin.vim @@ -20,24 +20,71 @@ let s:rs_repo = fnamemodify(s:plugin_dir, ':h:h') . '/nuwiki-rs' call mkdir(s:bin_dir, 'p') -function! s:target() abort - if has('win32') - return 'x86_64-pc-windows-msvc' - elseif has('mac') - if has('arm64') - return 'aarch64-apple-darwin' - endif - return 'x86_64-apple-darwin' - elseif has('linux') - let l:ldd = system('ldd --version 2>&1') - let l:musl = stridx(l:ldd, 'musl') >= 0 - let l:uname_m = trim(system('uname -m')) - if l:uname_m ==# 'aarch64' || l:uname_m ==# 'arm64' - return l:musl ? 'aarch64-unknown-linux-musl' : 'aarch64-unknown-linux-gnu' - endif - return l:musl ? 'x86_64-unknown-linux-musl' : 'x86_64-unknown-linux-gnu' +function! s:is_musl(arch) abort + if filereadable('/lib/ld-musl-' . a:arch . '.so.1') + return 1 endif - return '' + if !executable('ldd') + return 0 + endif + " musl's ldd writes its banner to stderr. + return stridx(system('ldd --version 2>&1'), 'musl') >= 0 +endfunction + +" NixOS and Guix System are not FHS distributions: glibc lives in the store, +" so the /lib64/ld-linux-*.so.2 a generic -gnu binary is linked against is +" either absent or — on NixOS — a stub that only prints an error and exits +" 127. `ldd --version` still reports GNU libc there, so libc detection alone +" picks the wrong asset. The musl builds are statically linked and need no +" loader at all, so they run on these systems unchanged. +function! s:is_non_fhs_linux() abort + if filereadable('/etc/NIXOS') + return 1 + endif + if !filereadable('/etc/os-release') + return 0 + endif + for l:line in readfile('/etc/os-release') + let l:id = matchstr(l:line, '^ID=\s*"\?\zs[[:alnum:]._-]\+') + if l:id ==# 'nixos' || l:id ==# 'guix' + return 1 + endif + endfor + return 0 +endfunction + +" Release triples to try, best first. More than one entry means the later ones +" are fallbacks for a host that turns out not to be able to run the first. +function! s:targets() abort + if get(g:, 'nuwiki_ls_target', '') !=# '' + return [g:nuwiki_ls_target] + endif + if has('win32') + return ['x86_64-pc-windows-msvc'] + elseif has('mac') + return [has('arm64') ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'] + elseif has('linux') + let l:uname_m = trim(system('uname -m')) + let l:arch = (l:uname_m ==# 'aarch64' || l:uname_m ==# 'arm64') + \ ? 'aarch64' : 'x86_64' + let l:musl = l:arch . '-unknown-linux-musl' + if s:is_musl(l:arch) || s:is_non_fhs_linux() + return [l:musl] + endif + " Static musl runs anywhere, so keep it as a last resort on glibc hosts + " too (old glibc, exotic loader layouts). + return [l:arch . '-unknown-linux-gnu', l:musl] + endif + return [] +endfunction + +" Can this host actually start the binary? Exit codes 126 and 127 mean the +" kernel or the dynamic loader refused to exec it (missing/stub loader, wrong +" architecture); any other code means it ran and exited on its own terms. +" system() gives the child an empty stdin, so the LSP loop sees EOF and stops. +function! s:can_exec(path) abort + call system(shellescape(a:path) . ' --version') + return v:shell_error != 126 && v:shell_error != 127 endfunction function! s:build_from_source() abort @@ -65,16 +112,12 @@ function! s:build_from_source() abort return executable(s:bin) endfunction -function! s:download_release() abort - let l:target = s:target() - if empty(l:target) - return 0 - endif +function! s:download_release(target) abort if !executable('curl') || !executable('tar') return 0 endif let l:url = 'https://code.gfran.co/gffranco/nuwiki-rs/releases/download/latest/nuwiki-ls-' - \ . l:target . '.tar.gz' + \ . a:target . '.tar.gz' let l:archive = tempname() . '.tar.gz' call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url)) if v:shell_error != 0 @@ -86,14 +129,28 @@ function! s:download_release() abort if v:shell_error != 0 return 0 endif - call system('cp ' . shellescape(l:extract . '/nuwiki-ls' . s:suffix) . ' ' . shellescape(s:bin)) + " Vet the staged copy before it becomes the installed one, so a binary this + " host cannot exec never lands at s:bin for the LSP client to pick up. + let l:staged = l:extract . '/nuwiki-ls' . s:suffix + call system('chmod +x ' . shellescape(l:staged)) + if !executable(l:staged) || !s:can_exec(l:staged) + return 0 + endif + call system('cp ' . shellescape(l:staged) . ' ' . shellescape(s:bin)) call system('chmod +x ' . shellescape(s:bin)) return executable(s:bin) endfunction -if s:download_release() - echom 'nuwiki: installed ' . s:bin -else +let s:installed = 0 +for s:t in s:targets() + if s:download_release(s:t) + echom 'nuwiki: installed ' . s:bin . ' (' . s:t . ')' + let s:installed = 1 + break + endif +endfor + +if !s:installed echohl WarningMsg | echom 'nuwiki: download failed, falling back to source build' | echohl None if !s:build_from_source() echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None