diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9765f3ee..34f8cbf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ name: ci +# push only on master: with both triggers unfiltered, a branch with a PR open +# builds everything twice. on: push: + branches: [master] pull_request: workflow_dispatch: @@ -13,201 +16,42 @@ defaults: run: shell: bash +# What a person does on a new machine: bootstrap, install, check it works. +# `zig build headless` owns the package list, zig/smoke.zig owns what "works" +# means, and nothing here knows either. jobs: - # Does bootstrap.sh produce the zig that build.zig.zon asks for, twice in a - # row, on every runner GitHub offers? - bootstrap: - name: bootstrap ${{ matrix.os }} + headless: + name: ${{ matrix.os }} runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.experimental || false }} + timeout-minutes: 60 strategy: fail-fast: false matrix: os: - ubuntu-24.04 - - ubuntu-24.04-arm - ubuntu-22.04 - - ubuntu-22.04-arm - macos-26 - macos-26-intel - macos-15 - include: - # zig ships .zip for windows and bootstrap.sh asks for .tar.xz - # unconditionally, so these stay red until that is handled. - - os: windows-2025 - experimental: true - - os: windows-11-arm - experimental: true steps: - uses: actions/checkout@v5 - uses: actions/cache@v4 with: - path: ~/.local/share/zig + path: | + ~/.local/share/zig + ~/.cache/zig key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('build.zig.zon') }} - run: ./bootstrap.sh - - name: version matches minimum_zig_version - run: | - want=$(sed -n 's/.*minimum_zig_version = "\([^"]*\)".*/\1/p' build.zig.zon) - got=$("$HOME/.local/bin/zig" version) - echo "want=$want got=$got" - test "$want" = "$got" - - - name: running it again is a no-op + - name: bootstrap again, expecting a no-op run: ./bootstrap.sh - # One cell per package per OS. Slow, but a red square names the package and - # the platform without anyone reading a log. - packages: - name: ${{ matrix.step }} ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - # Trim this list first if the queue gets painful; the OS axis finds - # far more than the step axis does. - os: - - ubuntu-24.04 - - ubuntu-24.04-arm - - ubuntu-22.04 - - macos-26 - - macos-26-intel - - macos-15 - step: - - ripgrep - - zls - - lua-ls - - treesitter - - nvim-plugins - - config - - rust - - fish - - nvim - include: - - step: ripgrep - smoke: rg --version - - step: zls - smoke: zls --version - - step: lua-ls - smoke: test -x "$HOME/.local/share/lua-language-server/bin/lua-language-server" - - step: treesitter - smoke: | - ls "$HOME/.local/share/nvim/site/parser/" - test "$(ls "$HOME/.local/share/nvim/site/parser/" | wc -l)" -eq 8 - test -d "$HOME/.local/share/nvim/site/queries/rust" - - step: nvim-plugins - smoke: | - ls "$HOME/.local/share/nvim/site/pack/dotfiles/start/" - test -f "$HOME/.local/share/nvim/site/pack/dotfiles/start/telescope-fzf-native.nvim/build/libfzf.so" - - step: config - smoke: | - test -d "$HOME/.config/nvim" - test -d "$HOME/.config/wezterm" - test -d "$HOME/.config/sway" - test -f "$HOME/.config/fish/conf.d/dotfiles.fish" - test -f "$HOME/.config/git/config" - - step: rust - smoke: | - "$HOME/.cargo/bin/cargo" --version - "$HOME/.cargo/bin/rustup" component list --installed | grep -q rust-analyzer - - step: fish - smoke: | - fish --version - fish -c 'echo hello from fish' - - step: nvim - smoke: nvim --version - steps: - - uses: actions/checkout@v5 - - - uses: actions/cache@v4 - with: - path: ~/.local/share/zig - key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('build.zig.zon') }} - - # The package cache, not the build cache: this is what stops 54 jobs - # re-downloading the same pinned tarballs. - - uses: actions/cache@v4 - with: - path: ~/.cache/zig - key: zigpkg-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('build.zig.zon') }} - - - run: ./bootstrap.sh - - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: zig build ${{ matrix.step }} - run: zig build ${{ matrix.step }} --summary all - - - name: smoke - run: ${{ matrix.smoke }} - - - name: what landed - if: always() - run: find "$HOME/.local/bin" "$HOME/.local/share" -maxdepth 3 2>/dev/null | sort | head -60 - - # The whole group at once, minus login-shell. See the note in the PR: chsh - # needs a PAM password on linux runners and would mutate the runner anyway, - # so it cannot be part of an unattended build. - integration: - name: integration ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-24.04, ubuntu-24.04-arm, macos-26, macos-26-intel] - steps: - - uses: actions/checkout@v5 - - - uses: actions/cache@v4 - with: - path: ~/.local/share/zig - key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('build.zig.zon') }} - - - uses: actions/cache@v4 - with: - path: ~/.cache/zig - key: zigpkg-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('build.zig.zon') }} - - - run: ./bootstrap.sh - - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: build everything headless except the login shell - run: | - zig build nvim rust fish lua-ls zls ripgrep nvim-plugins treesitter config --summary all - - # The real test: does neovim start with this config, find its parsers, - # and load every plugin, without a single error? --headless writes - # normal messages to stderr, so a non-empty stderr proves nothing -- - # :messages has to be read back and scanned instead. - - name: nvim starts clean with the real config - run: | - cat > /tmp/startup.lua <<'LUA' - local out = vim.api.nvim_exec2("messages", { output = true }).output - io.stdout:write(out .. "\n") - if out:match("E%d+:") or out:match("Error detected") or out:match("Error executing") then - io.stdout:write("^ nvim reported errors during startup\n") - vim.cmd("cq") - end - LUA - nvim --headless -c 'luafile /tmp/startup.lua' +qa - - - name: treesitter parsers actually load - run: | - cat > /tmp/parsers.lua <<'LUA' - local bad = {} - for _, lang in ipairs({ "rust", "zig", "nix", "toml", "json", "bash", "fish", "yaml" }) do - local ok, err = pcall(vim.treesitter.language.add, lang) - if not ok then table.insert(bad, lang .. " (" .. tostring(err) .. ")") end - end - if #bad > 0 then - io.stdout:write("failed to load: " .. table.concat(bad, ", ") .. "\n") - vim.cmd("cq") - end - io.stdout:write("all 8 parsers loaded\n") - LUA - nvim --headless -c 'luafile /tmp/parsers.lua' +qa + # failures, not all: a 90 line green tree buries the step that broke. + - run: zig build headless --summary failures - - name: fish runs the shipped config - run: fish -l -c 'echo $PATH; type -q nvim; and echo nvim visible to fish' + # all here: four lines, and it turns a silent pass into a test tally. + - run: zig build smoke --summary all diff --git a/.github/workflows/distros.yml b/.github/workflows/distros.yml index 1e32b622..2e225a66 100644 --- a/.github/workflows/distros.yml +++ b/.github/workflows/distros.yml @@ -1,7 +1,10 @@ name: distros +# push only on master: with both triggers unfiltered, a branch with a PR open +# builds everything twice. on: push: + branches: [master] pull_request: workflow_dispatch: schedule: @@ -15,81 +18,102 @@ defaults: run: shell: bash -# Containers are driven with `docker run` from an ubuntu runner rather than -# `container:` — actions/checkout needs a glibc node, which alpine does not -# have, and the point here is to test images that are deliberately bare. +# docker run rather than `container:` -- actions/checkout needs a glibc node, +# which alpine does not have, and these images are deliberately bare. env: RUN: >- docker run --rm -v ${{ github.workspace }}:/src:ro -e SETUP -e SCRIPT -w / jobs: - # What does bootstrap.sh actually require? Stock image, nothing added. - # A red square here is a finding to write down, not a build to fix in a rush. + # Which images run bootstrap.sh with nothing added? Reports rather than + # asserts: several are expected to lack curl or xz, and a square that is red + # on purpose is a square nobody reads. bare: - name: bare ${{ matrix.image }} + name: bare images runs-on: ubuntu-24.04 - continue-on-error: true - strategy: - fail-fast: false - matrix: - image: - - alpine:3.22 - - debian:13 - - debian:12 - - ubuntu:22.04 - - fedora:43 - - archlinux:latest - - rockylinux/rockylinux:9 steps: - uses: actions/checkout@v5 - - name: bootstrap with nothing installed - env: - SETUP: ":" - SCRIPT: | - ./bootstrap.sh - "$HOME/.local/bin/zig" version + - name: what does bootstrap.sh need? run: | - $RUN ${{ matrix.image }} sh -euc ' - cp -a /src /work && cd /work - eval "$SETUP" - eval "$SCRIPT" - ' + images=( + alpine:3.22 + debian:13 + debian:12 + ubuntu:22.04 + fedora:43 + archlinux:latest + rockylinux/rockylinux:9 + ) + + # Exit status is the verdict; output is only read when it fails. + bootstrap_in() { + local image=$1 + docker run --rm -v "$PWD:/src:ro" -w / "$image" sh -euc ' + cp -a /src /work + cd /work + ./bootstrap.sh + "$HOME/.local/bin/zig" version + ' 2>&1 + } + + # "wget: not found" and "xz: Cannot exec" become "wget xz". grep + # exits 1 on no match, which means a failure we have no name for -- + # an answer, not an error, so it must not trip `set -e`. + missing_commands() { + grep -Eio '[a-z0-9_-]+: (not found|cannot exec)' \ + | cut -d: -f1 \ + | sort -u \ + | paste -sd' ' - \ + || true + } + + rows=() + for image in "${images[@]}"; do + if output=$(bootstrap_in "$image"); then + verdict='works as shipped' + else + missing=$(missing_commands <<<"$output") + verdict="missing: ${missing:-unclear, read the log}" + fi + + echo "$image -> $verdict" + rows+=("| \`$image\` | $verdict |") + done + + { + echo '| image | bootstrap.sh as shipped |' + echo '| --- | --- |' + printf '%s\n' "${rows[@]}" + } >> "$GITHUB_STEP_SUMMARY" - # The same images with the smallest set of packages that makes bootstrap - # work. If a row here needs something new, that is the documented floor. - bootstrap: - name: bootstrap ${{ matrix.image }} + # The same thing ci.yml does, on distros GitHub does not offer as a runner. + # `setup` is the finding: the documented floor for that distro. + headless: + name: ${{ matrix.image }} runs-on: ubuntu-24.04 + timeout-minutes: 60 strategy: fail-fast: false matrix: include: - - image: alpine:3.22 - setup: apk add --no-cache ca-certificates tar xz - - image: debian:13 - setup: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils - image: debian:12 - setup: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils - - image: ubuntu:22.04 - setup: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils + setup: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils gcc libc6-dev passwd - image: fedora:43 - setup: dnf install -y tar xz - - image: archlinux:latest - setup: pacman -Sy --noconfirm --needed curl tar xz + setup: dnf install -y tar xz gcc glibc-devel util-linux-user - image: rockylinux/rockylinux:9 - setup: dnf install -y tar xz + setup: dnf install -y tar xz gcc glibc-devel util-linux-user steps: - uses: actions/checkout@v5 - - name: bootstrap and re-bootstrap + - name: bootstrap, install, smoke test env: SETUP: ${{ matrix.setup }} SCRIPT: | ./bootstrap.sh - want=$(sed -n 's/.*minimum_zig_version = "\([^"]*\)".*/\1/p' build.zig.zon) - got=$("$HOME/.local/bin/zig" version) - echo "want=$want got=$got"; test "$want" = "$got" ./bootstrap.sh + export PATH="$HOME/.local/bin:$PATH" + zig build headless --summary failures + zig build smoke --summary all run: | $RUN ${{ matrix.image }} sh -euc ' cp -a /src /work && cd /work @@ -97,10 +121,9 @@ jobs: eval "$SCRIPT" ' - # zig bundles clang, lld, musl and headers for 76 targets. If that claim is - # real, the tree-sitter grammars compile on an image with no gcc, no clang, - # no make and no pkg-config. This is the load-bearing assumption of the whole - # repo, so it gets its own job. + # zig bundles clang, lld, musl and headers for 76 targets. If that is real, + # the tree-sitter grammars compile with no gcc, clang, make or pkg-config. + # The load-bearing claim of the repo, so it gets to name a package. no-system-compiler: name: no cc ${{ matrix.image }} runs-on: ubuntu-24.04 @@ -126,80 +149,6 @@ jobs: export PATH="$HOME/.local/bin:$PATH" zig build treesitter --summary all ls "$HOME/.local/share/nvim/site/parser/" - test "$(ls "$HOME/.local/share/nvim/site/parser/" | wc -l)" -eq 8 - run: | - $RUN ${{ matrix.image }} sh -euc ' - cp -a /src /work && cd /work - eval "$SETUP" - eval "$SCRIPT" - ' - - # Prebuilt-download packages on musl and on old glibc. ripgrep picks the musl - # tarball on alpine and the gnu one elsewhere; zls and lua-ls have no musl - # build at all, which is the thing to find out. - prebuilts: - name: prebuilts ${{ matrix.image }} ${{ matrix.step }} - runs-on: ubuntu-24.04 - continue-on-error: ${{ matrix.experimental || false }} - strategy: - fail-fast: false - matrix: - step: [ripgrep, zls, lua-ls] - image: ["alpine:3.22", "debian:12", "rockylinux/rockylinux:9"] - include: - # ripgrep should pass here — it has a musl tarball. zls and lua-ls - # ship glibc-only builds, so this row is where that shows up. - - image: "alpine:3.22" - setup: apk add --no-cache ca-certificates tar xz - experimental: true - - image: debian:12 - setup: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils - - image: rockylinux/rockylinux:9 - setup: dnf install -y tar xz - steps: - - uses: actions/checkout@v5 - - env: - SETUP: ${{ matrix.setup }} - SCRIPT: | - ./bootstrap.sh - export PATH="$HOME/.local/bin:$PATH" - zig build ${{ matrix.step }} --summary all - find "$HOME/.local/bin" "$HOME/.local/share" -maxdepth 2 2>/dev/null | sort - run: | - $RUN ${{ matrix.image }} sh -euc ' - cp -a /src /work && cd /work - eval "$SETUP" - eval "$SCRIPT" - ' - - # rust and fish are the packages that reach outside zig: rustup-init writes - # $HOME/.cargo, and cargo needs a linker. This job documents what that costs - # per distro. - toolchain: - name: rust+fish ${{ matrix.image }} - runs-on: ubuntu-24.04 - continue-on-error: ${{ matrix.experimental || false }} - strategy: - fail-fast: false - matrix: - include: - - image: debian:12 - setup: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils gcc libc6-dev - - image: alpine:3.22 - setup: apk add --no-cache ca-certificates tar xz gcc musl-dev - experimental: true - steps: - - uses: actions/checkout@v5 - - env: - SETUP: ${{ matrix.setup }} - SCRIPT: | - ./bootstrap.sh - export PATH="$HOME/.local/bin:$PATH" - zig build rust --summary all - "$HOME/.cargo/bin/cargo" --version - zig build fish --summary all - "$HOME/.local/bin/fish" --version - "$HOME/.local/bin/fish" -c 'echo hello from fish' run: | $RUN ${{ matrix.image }} sh -euc ' cp -a /src /work && cd /work diff --git a/build.zig b/build.zig index df0286c3..1818d070 100644 --- a/build.zig +++ b/build.zig @@ -24,6 +24,8 @@ pub fn build(b: *std.Build) void { all.dependOn(group(env, "headless", "everything that runs without a display", headless)); all.dependOn(group(env, "gui", "desktop applications", gui)); + + smoke(env); } fn group(env: Env, name: []const u8, description: []const u8, comptime packages: anytype) *std.Build.Step { @@ -31,3 +33,23 @@ fn group(env: Env, name: []const u8, description: []const u8, comptime packages: inline for (packages) |pkg| step.dependOn(pkg.install(env)); return step; } + +// Deliberately independent of the headless step, so it can re-check an +// existing install without rebuilding. +fn smoke(env: Env) void { + const b = env.b; + + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("zig/smoke.zig"), + .target = b.graph.host, + .optimize = .Debug, + }), + }); + + const run = b.addRunArtifact(tests); + run.setEnvironmentVariable("DOTFILES_PREFIX", env.prefix); + run.has_side_effects = true; + + b.step("smoke", "check that an installed dotfiles actually works").dependOn(&run.step); +} diff --git a/zig/packages/config.zig b/zig/packages/config.zig index 6d01350d..12a14aab 100644 --- a/zig/packages/config.zig +++ b/zig/packages/config.zig @@ -4,7 +4,7 @@ const Env = @import("../Env.zig"); pub const name = "config"; pub const description = "symlink dotfiles into ~/.config"; -const links = [_][2][]const u8{ +pub const links = [_][2][]const u8{ .{ "fish/conf.d/dotfiles.fish", ".config/fish/conf.d/dotfiles.fish" }, .{ "wezterm", ".config/wezterm" }, .{ "nvim", ".config/nvim" }, diff --git a/zig/packages/login_shell.zig b/zig/packages/login_shell.zig index b2828fec..00ed8bd8 100644 --- a/zig/packages/login_shell.zig +++ b/zig/packages/login_shell.zig @@ -5,6 +5,8 @@ const fish = @import("fish.zig"); pub const name = "login-shell"; pub const description = "make the fish we build the login shell"; +// $SHELL rather than getpwuid: build.zig is compiled without libc on linux, so +// a std.c call here fails to compile the whole build graph. pub fn install(env: Env) *std.Build.Step { const b = env.b; const step = b.step(name, description); @@ -12,22 +14,23 @@ pub fn install(env: Env) *std.Build.Step { const shell = b.pathJoin(&.{ env.prefix, "bin", "fish" }); - if (currentShell()) |current| { + if (b.graph.environ_map.get("SHELL")) |current| { if (std.mem.eql(u8, current, shell)) return step; } - const register = if (isRegistered(b, shell)) "" else b.fmt( - "printf '%s\\n' \"{s}\" | sudo tee -a /etc/shells >/dev/null\n", - .{shell}, - ); - + // sudo so chsh does not ask PAM for a password; skipped as root, where + // there is nothing to elevate and often no sudo. The /etc/shells line is + // appended unconditionally -- duplicates are harmless. const run = b.addSystemCommand(&.{ "/bin/sh", "-c", b.fmt( \\set -e - \\"{0s}" -c 'exit 0' - \\{1s}chsh -s "{0s}" - , .{ shell, register }), + \\shell="{s}" + \\"$shell" -c 'exit 0' + \\if [ "$(id -u)" = 0 ]; then sudo=; else sudo=sudo; fi + \\printf '%s\n' "$shell" | $sudo tee -a /etc/shells >/dev/null + \\$sudo chsh -s "$shell" "$(id -un)" + , .{shell}), }); run.has_side_effects = true; @@ -36,18 +39,3 @@ pub fn install(env: Env) *std.Build.Step { step.dependOn(&run.step); return step; } - -fn currentShell() ?[]const u8 { - const pw = std.c.getpwuid(std.c.getuid()) orelse return null; - return std.mem.span(pw.shell orelse return null); -} - -fn isRegistered(b: *std.Build, shell: []const u8) bool { - const io = b.graph.io; - const text = std.Io.Dir.cwd().readFileAlloc(io, "/etc/shells", b.allocator, .limited(64 * 1024)) catch return false; - var lines = std.mem.tokenizeScalar(u8, text, '\n'); - while (lines.next()) |line| { - if (std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), shell)) return true; - } - return false; -} diff --git a/zig/packages/lua_ls.zig b/zig/packages/lua_ls.zig index a992aaa4..8f30a2a6 100644 --- a/zig/packages/lua_ls.zig +++ b/zig/packages/lua_ls.zig @@ -4,7 +4,7 @@ const Env = @import("../Env.zig"); pub const name = "lua-ls"; pub const description = "lua-language-server, from its prebuilt release"; -const subdir = "share/lua-language-server"; +pub const subdir = "share/lua-language-server"; pub fn install(env: Env) *std.Build.Step { const b = env.b; diff --git a/zig/packages/nvim_plugins.zig b/zig/packages/nvim_plugins.zig index f7af472e..3d6a11cb 100644 --- a/zig/packages/nvim_plugins.zig +++ b/zig/packages/nvim_plugins.zig @@ -4,9 +4,9 @@ const Env = @import("../Env.zig"); pub const name = "nvim-plugins"; pub const description = "neovim plugins, pinned in build.zig.zon"; -const pack = "share/nvim/site/pack/dotfiles/start"; +pub const pack = "share/nvim/site/pack/dotfiles/start"; -const plugins = [_][2][]const u8{ +pub const plugins = [_][2][]const u8{ .{ "plenary", "plenary.nvim" }, .{ "telescope", "telescope.nvim" }, .{ "web_devicons", "nvim-web-devicons" }, diff --git a/zig/packages/treesitter.zig b/zig/packages/treesitter.zig index 7dfe7a21..4afa2dcf 100644 --- a/zig/packages/treesitter.zig +++ b/zig/packages/treesitter.zig @@ -4,14 +4,14 @@ const Env = @import("../Env.zig"); pub const name = "treesitter"; pub const description = "tree-sitter grammars, compiled by zig"; -const Grammar = struct { +pub const Grammar = struct { lang: []const u8, dep: []const u8, scanner: bool = false, flags: []const []const u8 = &.{}, }; -const grammars = [_]Grammar{ +pub const grammars = [_]Grammar{ .{ .lang = "rust", .dep = "ts_rust", .scanner = true }, .{ .lang = "zig", .dep = "ts_zig" }, .{ .lang = "nix", .dep = "ts_nix", .scanner = true }, diff --git a/zig/smoke.zig b/zig/smoke.zig new file mode 100644 index 00000000..7dd11009 --- /dev/null +++ b/zig/smoke.zig @@ -0,0 +1,190 @@ +//! Smoke tests for an installed dotfiles tree, run against $DOTFILES_PREFIX. +//! +//! The lists come from the packages themselves, so adding a grammar or a +//! plugin extends the tests without touching this file. + +const std = @import("std"); +const testing = std.testing; + +const config = @import("packages/config.zig"); +const lua_ls = @import("packages/lua_ls.zig"); +const nvim_plugins = @import("packages/nvim_plugins.zig"); +const treesitter = @import("packages/treesitter.zig"); + +const gpa = testing.allocator; + +fn env(key: []const u8) ?[]const u8 { + return testing.environ.getPosix(key); +} + +fn prefix() []const u8 { + return env("DOTFILES_PREFIX") orelse + @panic("DOTFILES_PREFIX is not set; run this as `zig build smoke`"); +} + +const Path = struct { + buf: [std.fs.max_path_bytes]u8 = undefined, + + /// Resolve a path relative to the install prefix. + fn in(p: *Path, parts: []const []const u8) []const u8 { + var w: std.Io.Writer = .fixed(&p.buf); + w.writeAll(prefix()) catch @panic("path too long"); + for (parts) |part| { + w.writeByte('/') catch @panic("path too long"); + w.writeAll(part) catch @panic("path too long"); + } + return w.buffered(); + } + + /// Resolve a path relative to $HOME, for the rustup files outside the prefix. + fn home(p: *Path, parts: []const []const u8) []const u8 { + var w: std.Io.Writer = .fixed(&p.buf); + w.writeAll(env("HOME") orelse @panic("HOME is not set")) catch @panic("path too long"); + for (parts) |part| { + w.writeByte('/') catch @panic("path too long"); + w.writeAll(part) catch @panic("path too long"); + } + return w.buffered(); + } +}; + +fn expectExists(path: []const u8, options: std.Io.Dir.AccessOptions) !void { + std.Io.Dir.cwd().access(testing.io, path, options) catch |err| { + std.debug.print("missing: {s} ({s})\n", .{ path, @errorName(err) }); + return error.NotInstalled; + }; +} + +/// Run a command and require a clean exit. `needle` may appear on either +/// stream, since tools disagree about where to print a version. The timeout is +/// because a language server given arguments it dislikes waits on stdin. +fn expectRun(argv: []const []const u8, needle: ?[]const u8) !void { + const result = std.process.run(gpa, testing.io, .{ + .argv = argv, + .timeout = .{ .duration = .{ .raw = .fromSeconds(60), .clock = .awake } }, + }) catch |err| { + std.debug.print("could not run {s}: {s}\n", .{ argv[0], @errorName(err) }); + return error.CommandFailed; + }; + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + const failed = switch (result.term) { + .exited => |code| code != 0, + else => true, + }; + if (failed or (needle != null and + std.mem.indexOf(u8, result.stdout, needle.?) == null and + std.mem.indexOf(u8, result.stderr, needle.?) == null)) + { + std.debug.print( + \\command: {s} + \\term: {any} + \\stdout: {s} + \\stderr: {s} + \\ + , .{ argv[0], result.term, result.stdout, result.stderr }); + if (failed) return error.CommandFailed; + std.debug.print("expected the output to mention \"{s}\"\n", .{needle.?}); + return error.UnexpectedOutput; + } +} + +test "the binaries we install run" { + var p: Path = .{}; + + try expectRun(&.{ p.in(&.{ "bin", "nvim" }), "--version" }, "NVIM"); + try expectRun(&.{ p.in(&.{ "bin", "rg" }), "--version" }, "ripgrep"); + try expectRun(&.{ p.in(&.{ "bin", "zls" }), "--version" }, null); + try expectRun(&.{ p.in(&.{ "bin", "fish" }), "--version" }, "fish"); + try expectRun(&.{ p.home(&.{ ".cargo", "bin", "cargo" }), "--version" }, "cargo"); + + // Run it rather than stat it: this is the only dynamically linked binary + // we install, so present and runnable are different questions. + try expectRun(&.{ p.in(&.{ lua_ls.subdir, "bin", "lua-language-server" }), "--version" }, null); +} + +test "rust-analyzer is in the toolchain" { + var p: Path = .{}; + try expectRun( + &.{ p.home(&.{ ".cargo", "bin", "rustup" }), "component", "list", "--installed" }, + "rust-analyzer", + ); +} + +test "every config link resolves" { + var p: Path = .{}; + var buf: [std.fs.max_path_bytes]u8 = undefined; + + for (config.links) |link| { + const path = p.home(&.{link[1]}); + + // readLink proves it is ours rather than a file that happened to be + // there; access follows it, which proves the far end still exists. + _ = std.Io.Dir.cwd().readLink(testing.io, path, &buf) catch |err| { + std.debug.print("not a symlink: {s} ({s})\n", .{ path, @errorName(err) }); + return error.NotLinked; + }; + try expectExists(path, .{}); + } +} + +test "every plugin is unpacked" { + var p: Path = .{}; + + for (nvim_plugins.plugins) |plugin| { + try expectExists(p.in(&.{ nvim_plugins.pack, plugin[1] }), .{}); + } + + // Telescope silently falls back to a slow pure-lua sorter without this. + try expectExists( + p.in(&.{ nvim_plugins.pack, "telescope-fzf-native.nvim", "build", "libfzf.so" }), + .{}, + ); +} + +test "every grammar is compiled" { + var p: Path = .{}; + + inline for (treesitter.grammars) |grammar| { + try expectExists(p.in(&.{ "share/nvim/site/parser", grammar.lang ++ ".so" }), .{}); + try expectExists(p.in(&.{ "share/nvim/site/queries", grammar.lang }), .{}); + } +} + +/// nvim --headless writes ordinary messages to stderr, so :messages has to be +/// read back and scanned instead. +const startup = + "lua local out = vim.api.nvim_exec2('messages', {output=true}).output " ++ + "io.stdout:write(out) " ++ + "if out:match('E%d+:') or out:match('Error detected') or out:match('Error executing') " ++ + "then vim.cmd('cq') end"; + +test "nvim starts clean with the shipped config" { + var p: Path = .{}; + try expectRun(&.{ p.in(&.{ "bin", "nvim" }), "--headless", "-c", startup, "+qa" }, null); +} + +/// Installed is not loadable: a wrong-ABI grammar only fails here. +const load_parsers = blk: { + var langs: []const u8 = ""; + for (treesitter.grammars, 0..) |grammar, i| { + langs = langs ++ (if (i == 0) "" else ",") ++ "'" ++ grammar.lang ++ "'"; + } + break :blk "lua local bad = {} " ++ + "for _, lang in ipairs({" ++ langs ++ "}) do " ++ + "local ok, err = pcall(vim.treesitter.language.add, lang) " ++ + "if not ok then bad[#bad+1] = lang .. ': ' .. tostring(err) end end " ++ + "if #bad > 0 then io.stdout:write(table.concat(bad, '\\n')) vim.cmd('cq') end"; +}; + +test "nvim loads every grammar" { + var p: Path = .{}; + try expectRun(&.{ p.in(&.{ "bin", "nvim" }), "--headless", "-c", load_parsers, "+qa" }, null); +} + +test "fish runs the shipped config" { + var p: Path = .{}; + // Set by conf.d; empty if the symlink or fish_add_path broke. + try expectRun(&.{ p.in(&.{ "bin", "fish" }), "-l", "-c", "echo $EDITOR" }, "nvim"); +}