# an out-of-band plugin manager for neovim. # cannot do a lot, compared to "normal" neovim plugin managers, but: # - does not incur runtime costs on neovim # - uses built-in vim packages let tar_command = if $nu.os-info.name == 'windows' { [$env.SystemRoot System32 tar.exe] | path join } else { 'tar' } def require-zig [] { if (which zig | is-empty) { error make { msg: 'puk requires Zig in PATH.' } } } def refuse-running-neovim [] { let processes = ps | where { |process| let name = $process.name | path basename | str lowercase $name in [ 'nvim' 'nvim.exe' ] } if not ($processes | is-empty) { let pids = $processes.pid | each { into string } | str join ', ' [ $"Error: Neovim is running, PIDs: ($pids). Plugin installation stopped." 'Why: updating Treesitter parsers while Neovim holds parser DLLs can leave stale files and break health checks.' 'Action: close every Neovim instance, then rerun `just puk`.' ] | str join (char nl) | print --stderr exit 1 } } def main [] { require-zig refuse-running-neovim let paths_result = (nvim --headless --clean --cmd 'lua io.stdout:write(vim.json.encode({ data = vim.fn.stdpath("data"), cache = vim.fn.stdpath("cache") }))' +q | complete) if $paths_result.exit_code != 0 { error make { msg: $"Neovim path lookup failed: ($paths_result.stderr)" } } let paths = $paths_result.stdout | from json let data_path = $paths.data let cache_path = $paths.cache if not ($cache_path | path exists) { print $"creating cache path: ($cache_path)" mkdir $cache_path } let plugins_path = [ $data_path site pack puk opt ] | path join let site_path = [ $data_path site ] | path join let base = $env.PWD let plugin_specs = (open ($base | path join puk.plugins.nuon) | collect) let parser_specs = (open ($base | path join puk.parsers.nuon) | collect) if (not ($parser_specs | is-empty)) and ((which tree-sitter | is-empty)) { print "puk.parsers.nuon declares parsers but the tree-sitter CLI is not in PATH." print "Install tree-sitter-cli (>= 0.26) first." exit 1 } let all_specs = ($plugin_specs | append $parser_specs) let total = $all_specs | length print $"Installing plugins and parsers from puk.*.nuon. total: ($total)" let pending_hooks = $plugin_specs | where { |spec| ($spec.post_install? | default null) != null and not (plugin-is-installed $spec $plugins_path) } let plugin_errors = $plugin_specs | par-each { install-spec $in $plugins_path $site_path $cache_path } | flatten let hook_errors = $pending_hooks | where { |spec| $spec.name not-in $plugin_errors.name } | each { |spec| finish-plugin $spec $plugins_path } | flatten let parser_errors = $parser_specs | each { install-spec $in $plugins_path $site_path $cache_path } | flatten let errors = ($plugin_errors | append $hook_errors | append $parser_errors) print 'Cleaning plugins, that are not part of puk.plugins.nuon.' clean-plugins $plugins_path $plugin_specs print 'Cleaning stale cache archives.' clean-cache $cache_path $all_specs # plugins need to be packadded before helptags can be generated print 'Generating helptags.' mut packs = $plugin_specs | par-each { | spec | $"packadd ($spec.name)" } | str join "\n" nvim -u NONE --headless --cmd $packs --cmd 'helptags ALL' +q if not ($errors | is-empty) { let failed = $errors | length print $"\n($failed)/($total) plugins FAILED:" $errors | each { |e| print $" - ($e.name): ($e.error)" } exit 1 } } # check online for fresh commits. Does not actually mutate or update anything. # updated plugin specs will be written to a new file. def 'main update' [] { require-zig update-file puk.plugins.nuon update-file puk.parsers.nuon } # check one spec nuon file for fresh upstream commits; writes updated specs to a new file. def update-file [ path: string # nuon file with plugin or parser specs ] { print $"Checking for fresh commits of specs in ($path)..." let specs = open $path let updated = $specs | update commit { |spec| let fresh_commit = find-latest-commit $spec if ($fresh_commit != $spec.commit) { print $"Found fresh commit for ($spec.name): ($fresh_commit)!" $fresh_commit } else { $spec.commit } } if not ($specs == $updated) { let target = ($path | str replace '.nuon' '.updated.nuon') $updated | to nuon --indent 2 | save --force $target print $"Written updated specs into ($target)!" } else { print "Nothing to do." } } # deletes all existing plugins, that are NOT in the specs! def clean-plugins [ plugins_path: path # folder to install plugins into plugin_specs: list # all known plugins ] { ls $plugins_path | each { | row | let existing_plugin_name = $row.name | path basename if ( $plugin_specs | where name == $existing_plugin_name | is-empty) { print $"Found plugin, that is not part of the specs: ($existing_plugin_name)" rm --trash --recursive $row.name } } } # deletes all cache archives, that are NOT matching any spec's name+commit! def clean-cache [ cache_path: path # folder containing cached archives plugin_specs: list # all known plugins ] { let expected = $plugin_specs | each { |spec| (make-archive-path $spec $cache_path | path basename) } try { glob $"($cache_path | str replace --all '\' '/')/*.tar.gz" | each { |path| let name = $path | path basename if ($expected | where $it == $name | is-empty) { print $"Removing stale cache: ($name)" rm $path } } } catch {} } # given a plugin spec, and a plugins base install directory, this method generates # a new path, that points to the location the plugin should be installed into. def make-plugin-path [ spec: record # a plugin to install plugins_path: path # folder to install plugins into ] { [$plugins_path $spec.name] | path join } # given a path to an installed plugin, this method generates a path to a file holding # the commit of the plugin. even though git is not really involved in installing # plugins here, we use the commit as a kind of version number and lock file in one. def make-commit-file-path [ plugin_path: path # path to an installed plugin ] { [$plugin_path .commit.puk] | path join } def plugin-is-installed [ spec: record # a plugin to install plugins_path: path # folder to install plugins into ] { let predicted_plugin_path = make-plugin-path $spec $plugins_path if not ( $predicted_plugin_path | path exists ) { return false } let commit_file_path = make-commit-file-path $predicted_plugin_path if not ($commit_file_path | path exists) { return false } let commit = open --raw $commit_file_path | str trim return ($commit == $spec.commit) } def find-latest-commit [ spec: record # a plugin to install ] { let feed = generate-commit-feed-url $spec let commit = match $spec.source { github => { try { http get --max-time 5sec $feed | lines | parse --regex "Commit/(?[a-f0-9]+)" | first | get commit } catch { print $"Timed out or failed fetching ($spec.name), keeping current commit." $spec.commit } }, codeberg => { 'TODO' }, srht => { 'TODO' }, _ => { error make { msg: 'unknown plugin source given' label: { text: 'dunno this one' span: (metadata $spec.source).span } } } } $commit } def generate-commit-feed-url [ spec: record # a plugin to install ] { let url_format = match $spec.source { github => { 'https://github.com/{repo}/commits/{branch}.atom' }, codeberg => { 'TODO' }, srht => { 'TODO' }, _ => { error make { msg: 'unknown plugin source given' label: { text: 'dunno this one' span: (metadata $spec.source).span } } } } $spec | format pattern $url_format } def generate-download-url [ spec: record # a plugin to install ] { let url_format = match $spec.source { github => { 'https://github.com/{repo}/archive/{commit}.tar.gz' }, codeberg => { 'https://codeberg.org/{repo}/archive/{commit}.tar.gz' }, srht => { 'https://git.sr.ht/{repo}/archive/{commit}.tar.gz' }, _ => { error make { msg: 'unknown plugin source given' label: { text: 'dunno this one' span: (metadata $spec.source).span } } } } $spec | format pattern $url_format } def make-archive-path [ spec: record # a plugin to install cache_path: path # folder to cache downloaded archives into ] { [ $cache_path $"($spec.name)-($spec.commit).tar.gz" ] | path join } def download-plugin [ spec: record # a plugin to install cache_path: path # folder to cache downloaded archives into ] { let archive_path = make-archive-path $spec $cache_path if ($archive_path | path exists) { let checked = (run-external $tar_command '--list' '--file' $archive_path | complete) if $checked.exit_code == 0 { print $"($spec.name) already cached in ($archive_path)" return $archive_path } rm $archive_path } let url = generate-download-url $spec let partial_path = mktemp --tmpdir-path $cache_path 'puk-download.XXXXXX' try { try { http get --raw --max-time 5sec $url | save --force $partial_path } catch { print $"Download of ($spec.name) failed, retrying..." http get --raw --max-time 10sec $url | save --force $partial_path } let checked = (run-external $tar_command '--list' '--file' $partial_path | complete) if $checked.exit_code != 0 { error make { msg: $"($spec.name): invalid archive: ($checked.stderr)" } } mv $partial_path $archive_path $archive_path } finally { if ($partial_path | path exists) { rm $partial_path } } } def tree-hash [dir: path] { glob $"($dir | str replace --all '\' '/')/**/*" | where { |f| ($f | path type) == file } | each { |f| { rel: ($f | path relative-to $dir), h: (open --raw $f | hash sha256) } } } def diff-hashes [base: path, hashes: list, label: string, name: string] { let now = (tree-hash $base) $hashes | each { |x| let y = ($now | where rel == $x.rel) if ($y | is-empty) { $"VERIFY ($name) ($label): missing ($x.rel)" } else if ($y | first | get h) != $x.h { $"VERIFY ($name) ($label): content ($x.rel)" } else { '' } } | where { |s| $s != '' } } def unpack-plugin-archive [ spec: record # a plugin to install archive_path: path # path to a plugin archive ] { let tmp_path = mktemp --directory try { let extracted = (run-external $tar_command '--extract' '--file' $archive_path '--directory' $tmp_path '--strip-components=1' | complete) if $extracted.exit_code != 0 { error make { msg: $"($spec.name): extraction failed: ($extracted.stderr)" } } if $spec.build != null { print $"Build step for plugin ($spec.name) detected! Executing now..." run-nu-in-dir $tmp_path $spec.build } $tmp_path } catch { |e| rm --recursive $tmp_path error make { msg: $e.msg } } } let allowed_shared_lib_type = match $nu.os-info.name { windows => 'dll', macos => 'dylib', _ => 'so', } let base_filetypes = [ lua vim scm txt $allowed_shared_lib_type ] let exclusions = [ **/tests/**, **/test/**, **/perf/**, **/scripts/**, **/fixtures/**, **/scrapers/**, **/spec/**, **/target/release/deps/** ] def install-plugin [ spec: record # a plugin to install folder_path: path # unpacked path to plugin folder plugins_path: path # folder to install plugins into ] { let plugin_path = make-plugin-path $spec $plugins_path let tmp_path = [$plugins_path $".tmp.($spec.name)"] | path join try { rm --recursive $tmp_path } mkdir $tmp_path let filetypes = if ($spec.extra_extensions? | default null) != null { $base_filetypes | append $spec.extra_extensions } else { $base_filetypes } let copied = (glob $"($folder_path | str replace --all '\' '/')/**/*.{($filetypes | str join ',')}" --exclude $exclusions | each { |f| { rel: ($f | path relative-to $folder_path), h: (open --raw $f | hash sha256) } }) $copied | each { | entry | let rebased_parent = [$tmp_path ($entry.rel | path dirname)] | path join mkdir $rebased_parent cp ([$folder_path $entry.rel] | path join) $rebased_parent } let copy_problems = (diff-hashes $tmp_path $copied 'copy' $spec.name) if not ($copy_problems | is-empty) { error make { msg: $"($spec.name) copy verification failed: ($copy_problems | str join ', ')" } } try { rm --trash --recursive $plugin_path } mv $tmp_path $plugin_path let move_problems = (diff-hashes $plugin_path $copied 'move' $spec.name) if not ($move_problems | is-empty) { rm --trash --recursive $plugin_path error make { msg: $"($spec.name) move verification failed: ($move_problems | str join ', ')" } } $plugin_path } def run-nu-in-dir [ dir: path command: string ] { cd $dir let result = (run-external $nu.current-exe '--no-config-file' '--commands' $command | complete) print --no-newline $result.stdout print --stderr --no-newline $result.stderr if $result.exit_code != 0 { error make { msg: $"Build failed with exit code ($result.exit_code)" } } } def install-spec-parser [ spec: record # a parser to install site_path: path # nvim site dir (parsers/queries install root) cache_path: path # folder to cache downloaded archives into ] { let parser_so = [$site_path parser $"($spec.name).so"] | path join let pin_path = [$site_path parser $".($spec.name).commit.puk"] | path join let queries_target = [$site_path queries $spec.name] | path join let has_queries = ($spec.queries? | default null) != null let queries_ready = if $has_queries { (glob $"($queries_target | str replace --all '\' '/')/*.scm" | length) > 0 } else { true } if ($parser_so | path type) == file and $queries_ready and ($pin_path | path type) == file and ((open --raw $pin_path | str trim) == $spec.commit) { return [] } try { print $"Downloading parser ($spec.name)..." let archive_path = download-plugin $spec $cache_path print $"Unpacking parser ($spec.name)..." let unpacked_path = unpack-plugin-archive { name: $spec.name, build: null } $archive_path try { let root = if ($spec.location? | default null) != null { [$unpacked_path $spec.location] | path join } else { $unpacked_path } let source_ready = if $spec.generate { ($root | path join src grammar.json | path exists) or ($root | path join grammar.js | path exists) } else { $root | path join src parser.c | path exists } if not $source_ready { error make { msg: $"($spec.name): tarball is missing parser source or grammar" } } if $spec.generate { print $"Generating parser ($spec.name)..." let grammar = if ($root | path join src grammar.json | path exists) { $root | path join src grammar.json } else { $root | path join grammar.js } let gen = (tree-sitter generate $grammar '--output' ($root | path join src) | complete) if $gen.exit_code != 0 { error make { msg: $"($spec.name): tree-sitter generate failed: ($gen.stderr)" } } } print $"Compiling parser ($spec.name)..." let so_tmp = ($root | path join $"($spec.name).so") let compiler_env = { CC: 'zig cc', CRATE_CC_NO_DEFAULTS: '1', CFLAGS: (if $nu.os-info.name == 'windows' { $"-target ($nu.os-info.arch)-windows-gnu -O2" } else { '-O2' }) } let built = with-env $compiler_env { tree-sitter build '--output' $so_tmp $root | complete } if $built.exit_code != 0 { error make { msg: $"($spec.name): tree-sitter build failed: ($built.stderr)" } } if not ($so_tmp | path exists) { error make { msg: $"($spec.name): compile produced no .so" } } let staged_queries = [$unpacked_path '.puk-queries'] | path join if $has_queries { let queries = glob $"([$root $spec.queries] | path join | str replace --all '\' '/')/*.scm" if ($queries | is-empty) { error make { msg: $"($spec.name): no .scm queries found" } } mkdir $staged_queries $queries | each { |f| cp $f $staged_queries } | ignore } mkdir ($site_path | path join parser) let old_parser = [$unpacked_path '.puk-old-parser'] | path join let old_queries = [$unpacked_path '.puk-old-queries'] | path join let old_pin = if ($pin_path | path type) == file { open --raw $pin_path } else { null } if ($parser_so | path exists) { cp $parser_so $old_parser } mut queries_replaced = false let failure = try { if $has_queries { mkdir ($queries_target | path dirname) if ($queries_target | path exists) { mv $queries_target $old_queries } $queries_replaced = true mv $staged_queries $queries_target } cp --force $so_tmp $parser_so $spec.commit | save --force $pin_path null } catch { |e| $e } if $failure != null { if ($old_parser | path exists) { cp --force $old_parser $parser_so } else if ($parser_so | path exists) { rm $parser_so } if $queries_replaced { if ($queries_target | path exists) { rm --recursive $queries_target } if ($old_queries | path exists) { mv $old_queries $queries_target } } if $old_pin != null { $old_pin | save --force $pin_path } else if ($pin_path | path type) == file { rm $pin_path } error make { msg: $failure.msg } } print $"Parser ($spec.name) installed into ($parser_so)!" [] } finally { rm --recursive $unpacked_path } } catch { |e| print $"FAILED parser ($spec.name): ($e.msg)" return [{ name: $spec.name, error: $e.msg }] } } def install-spec [ spec: record # a plugin or parser to install plugins_path: path # folder to install plugins into site_path: path # nvim site dir (parser install root) cache_path: path # folder to cache downloaded archives into ] { if ($spec.generate? != null) { return (install-spec-parser $spec $site_path $cache_path) } if (plugin-is-installed $spec $plugins_path) { return [] } try { print $"Downloading ($spec.name)..." let archive_path = download-plugin $spec $cache_path print $"Unpacking ($spec.name)..." let unpacked_path = unpack-plugin-archive $spec $archive_path try { print $"Installing ($spec.name)..." let installed_plugin_path = install-plugin $spec $unpacked_path $plugins_path if ($spec.post_install? | default null) == null { $spec.commit | save --force (make-commit-file-path $installed_plugin_path) } print $"Plugin ($spec.name) installed into ($installed_plugin_path)!" [] } finally { rm --recursive $unpacked_path } } catch { |e| print $"FAILED ($spec.name): ($e.msg)" return [{ name: $spec.name, error: $e.msg }] } } def finish-plugin [spec: record, plugins_path: path] { try { print $"Running post-install for ($spec.name)..." let commands = $spec.post_install | each { |cmd| $cmd | to json --raw } | str join ', ' let script = 'lua for _, command in ipairs({%s}) do local ok, err = pcall(vim.cmd, command); if not ok then vim.api.nvim_err_writeln(tostring(err)); vim.cmd.cquit(1) end end' | str replace '%s' $commands let result = (nvim --headless -c $script +qa | complete) print --no-newline $result.stdout print --stderr --no-newline $result.stderr if $result.exit_code != 0 { error make { msg: $"($spec.name) post-install failed with exit code ($result.exit_code)" } } $spec.commit | save --force (make-commit-file-path (make-plugin-path $spec $plugins_path)) [] } catch { |e| [{ name: $spec.name, error: $e.msg }] } }