# an out-of-band plugin manager for neovim. # cannot do a lot, compared to "normal" neovim plugin managers, but: # - it does not depend on git, only on tar and curl (and nushell right now, but that could change) # - tar and curl should be available on any system that neovim is installed, even win32 # - does not incur runtime costs on neovim # - uses built-in vim packages 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 [] { refuse-running-neovim let data_path = nvim --headless --clean +'lua print(vim.fn.stdpath "data")' +q | complete | get stderr let cache_path = nvim --headless --clean +'lua print(vim.fn.stdpath "cache")' +q | complete | get stderr 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) and a C compiler 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)" # plugins install in parallel; parser installs stay sequential (nushell par-each # threads race on shared interpreter state for external-heavy multi-stage jobs) let plugin_errors = $plugin_specs | par-each { install-spec $in $plugins_path $site_path $cache_path } | flatten let parser_errors = $parser_specs | each { install-spec $in $plugins_path $site_path $cache_path } | flatten let errors = ($plugin_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' [] { 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)/*.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) { print $"($spec.name) already cached in ($archive_path)" return $archive_path } let url = generate-download-url $spec try { http get --raw --max-time 5sec $url | save $archive_path } catch { print $"Download of ($spec.name) timed out, retrying..." http get --raw --max-time 10sec $url | save $archive_path } $archive_path } def tree-hash [dir: path] { glob $"($dir)/**/*" | where { |f| ($f | path type) == file } | each { |f| { rel: ($f | str replace $"($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 # hopefully strip-componets works on win32 as well print $archive_path $tmp_path let tar_command = if (sys host | get name) =~ 'Windows' { [$env.SystemRoot System32 tar.exe] | path join } else { 'tar' } run-external $tar_command "--extract" "--file" $archive_path "--directory" $tmp_path "--strip-components=1" let tmp2 = (mktemp --directory) run-external $tar_command "--extract" "--file" $archive_path "--directory" $tmp2 "--strip-components=1" let extract_problems = (diff-hashes $tmp2 (tree-hash $tmp_path) 'extract' $spec.name) if not ($extract_problems | is-empty) { $extract_problems | each { |l| print $l } } rm --recursive $tmp2 if $spec.build != null { print $"Build step for plugin ($spec.name) detected! Executing now..." run-shell-in-dir $tmp_path ($spec.build | str join ' ') } $tmp_path } let allowed_shared_lib_type = match ( sys host | get name ) { $name if $name =~ 'Windows' => { 'dll' }, $name if $name =~ 'Linux' => { 'so' }, $name if $name =~ 'NixOS' => { 'so' }, $name if $name =~ 'Mac' => { 'dylib' }, $name if $name =~ 'Pixel' => { 'so' }, _ => { error make { msg: 'unknown operating system' } }, } 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)/**/*.{($filetypes | str join ',')}" --exclude $exclusions | each { |f| { rel: ($f | str replace $"($folder_path)/" ''), h: (open --raw $f | hash sha256) } }) $copied | each { | entry | let rebased_parent = [$tmp_path ($entry.rel | path dirname | to text)] | path join mkdir $rebased_parent cp ([$folder_path $entry.rel] | path join) $rebased_parent } let commit_file_path = make-commit-file-path $tmp_path $spec.commit | save --force $commit_file_path 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 } # run a build command line inside a directory, using the platform shell so puk # itself never depends on a specific one (win32 ships cmd.exe, unix ships bash) def run-shell-in-dir [ dir: path # directory to run the command in command: string # shell command line ] { if (sys host | get name) =~ 'Windows' { run-external cmd.exe "/S" "/C" $"cd /d ($dir) && ($command)" } else { run-external bash "-c" $"cd '($dir)' && ($command)" } } 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 if ($pin_path | path exists) 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 let root = if ($spec.location? | default null) != null { [$unpacked_path $spec.location] | path join } else { $unpacked_path } if not (($root | path join src parser.c | path exists) or ($root | path join src grammar.json | path exists)) { error make { msg: $"($spec.name): tarball has no src/parser.c or src/grammar.json (submodule gotcha?)" } } let build_dir = mktemp --directory 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 src grammar.js } let gen = (tree-sitter generate $grammar | 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 built = (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" } } mkdir ($site_path | path join parser) cp $so_tmp $parser_so rm $so_tmp if ($spec.queries? | default null) != null { let queries_target = [$site_path queries $spec.name] | path join try { rm --recursive $queries_target } mkdir $queries_target glob $"([$root $spec.queries] | path join)/*.scm" | each { |f| cp $f $queries_target } if ((glob $"($queries_target)/*.scm" | length) == 0) { error make { msg: $"($spec.name): no .scm queries found" } } } $spec.commit | save --force $pin_path try { rm --recursive $build_dir } try { rm --recursive $unpacked_path } print $"Parser ($spec.name) installed into ($parser_so)!" return [] } 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 print $"Installing ($spec.name)..." let installed_plugin_path = install-plugin $spec $unpacked_path $plugins_path if ($spec.post_install? | default null) != null { print $"Running post-install for ($spec.name)..." try { run-external nvim "--headless" ...($spec.post_install | each { |cmd| $"+($cmd)" } | append "+qa") } catch { rm --trash --recursive $installed_plugin_path error make { msg: $"($spec.name) post-install failed" } } } print $"Plugin ($spec.name) installed into ($installed_plugin_path)!" return [] } catch { |e| print $"FAILED ($spec.name): ($e.msg)" return [{ name: $spec.name, error: $e.msg }] } }