repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
neovim/puk.nu
Raw# 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<any> # 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<any> # 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/(?<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 }]
}
}