Luigit
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 }]
	}
}