diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 72c9a49aca..aa2c8d6049 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,6 +9,9 @@ on: paths: - "rust/**" - "src/jsHelper/**" + - "install.sh" + - "install.ps1" + - "scripts/test-install-completions.*" - "scripts/build-payload.mjs" - ".github/workflows/rust.yml" push: @@ -19,6 +22,9 @@ on: paths: - "rust/**" - "src/jsHelper/**" + - "install.sh" + - "install.ps1" + - "scripts/test-install-completions.*" - "scripts/build-payload.mjs" - ".github/workflows/rust.yml" @@ -43,6 +49,17 @@ jobs: - name: Show toolchain run: rustup show active-toolchain + - name: Test POSIX shell completion installation + if: runner.os != 'Windows' + working-directory: . + run: sh scripts/test-install-completions.sh + + - name: Test PowerShell completion installation + if: runner.os == 'Windows' + working-directory: . + shell: pwsh + run: ./scripts/test-install-completions.ps1 + - name: Setup Node and pnpm uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 diff --git a/install.ps1 b/install.ps1 index 19bd608049..ee6368688b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -155,6 +155,38 @@ function Add-SpicetifyToPath { } } +function Add-SpicetifyCompletion { + [CmdletBinding()] + param ( + [string] $ProfilePath = $PROFILE + ) + begin { + Write-Host -Object 'Adding spicetify shell completion...' -NoNewline + $completion = '$env:COMPLETE = "powershell"; spicetify | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE' + } + process { + $profileDirectory = Split-Path -Parent $ProfilePath + if (-not (Test-Path -LiteralPath $profileDirectory)) { + New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null + } + if (-not (Test-Path -LiteralPath $ProfilePath)) { + New-Item -ItemType File -Path $ProfilePath -Force | Out-Null + } + + $alreadyInstalled = Select-String -LiteralPath $ProfilePath -SimpleMatch $completion -Quiet + if (-not $alreadyInstalled) { + $profileContent = Get-Content -LiteralPath $ProfilePath -Raw + if ($profileContent.Length -gt 0 -and -not $profileContent.EndsWith("`n")) { + Add-Content -LiteralPath $ProfilePath -Value ([Environment]::NewLine) -NoNewline + } + Add-Content -LiteralPath $ProfilePath -Value $completion + } + } + end { + Write-Success + } +} + function Install-Spicetify { [CmdletBinding()] param () @@ -214,6 +246,9 @@ else { #region Spicetify Move-OldSpicetifyFolder Install-Spicetify +if ($v3) { + Add-SpicetifyCompletion +} Write-Host -Object "`nRun" -NoNewline Write-Host -Object ' spicetify -h ' -NoNewline -ForegroundColor 'Cyan' Write-Host -Object 'to get started' diff --git a/install.sh b/install.sh index 526aecb369..1977903f1d 100755 --- a/install.sh +++ b/install.sh @@ -139,10 +139,67 @@ export PATH="\$PATH:$spicetify_install" EOINFO } +# BEGIN shell completion helpers endswith_newline() { - [ "$(od -An -c "$1" | tail -1 | grep -o '.$')" = "\n" ] + [ ! -s "$1" ] || [ "$(od -An -c "$1" | tail -1 | grep -o '.$')" = "\n" ] } +append_completion() { + shellrc=$1 + completion=$2 + + if ! mkdir -p "$(dirname "$shellrc")" || ! touch "$shellrc"; then + log "Could not update $shellrc. Add this line manually:" + log "$completion" + return + fi + + if grep -Fq "$completion" "$shellrc"; then + log "spicetify completion already set in $shellrc, continuing..." + return + fi + + log "ADDING spicetify completion to $shellrc" + if ! endswith_newline "$shellrc"; then + echo >> "$shellrc" + fi + echo "$completion" >> "$shellrc" +} + +install_shell_completion() { + if [ "$channel" != "v3" ]; then + return 0 + fi + + case ${SHELL:-} in + *zsh) + shellrc=${ZDOTDIR:-$HOME}/.zshrc + append_completion "$shellrc" "source <(COMPLETE=zsh spicetify)" + ;; + *bash) + found_bashrc=0 + for shellrc in "$HOME/.bashrc" "$HOME/.bash_profile"; do + if [ -f "$shellrc" ]; then + append_completion "$shellrc" "source <(COMPLETE=bash spicetify)" + found_bashrc=1 + fi + done + if [ "$found_bashrc" -eq 0 ]; then + append_completion "$HOME/.bashrc" "source <(COMPLETE=bash spicetify)" + fi + ;; + *fish) + shellrc=${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions/spicetify.fish + append_completion "$shellrc" "COMPLETE=fish spicetify | source" + ;; + *elvish) + append_completion "$HOME/.elvish/rc.elv" "eval (E:COMPLETE=elvish spicetify | slurp)" + ;; + *) log "Shell completion was not configured for ${SHELL:-the current shell}." ;; + esac +} +# END shell completion helpers + check() { path="export PATH=\$PATH:$spicetify_install" shellrc=$HOME/$1 @@ -154,7 +211,10 @@ check() { # Create shellrc if it doesn't exist if ! [ -f "$shellrc" ]; then log "CREATING $shellrc" - touch "$shellrc" + if ! mkdir -p "$(dirname "$shellrc")" || ! touch "$shellrc"; then + notfound + return + fi fi # Still checking again, in case touch command failed @@ -183,9 +243,12 @@ case $SHELL in [ -f "$HOME/.bash_profile" ] && check ".bash_profile" ;; *fish) check ".config/fish/config.fish" "fish_add_path $spicetify_install" ;; + *elvish) check ".elvish/rc.elv" "set E:PATH = '$spicetify_install:'\$E:PATH" ;; *) notfound ;; esac +install_shell_completion + case ":$PATH:" in *":$spicetify_install:"*) ;; *) export PATH="$spicetify_install:$PATH" ;; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 79980f1cb8..0cd2e8390b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -401,6 +401,18 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -425,6 +437,7 @@ version = "3.0.0-beta.12" dependencies = [ "anyhow", "clap", + "clap_complete", "i18n-embed-fl", "spicetify", "tui", @@ -1552,6 +1565,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_executable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7d67a09d4f..f0be62ae6e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -55,6 +55,7 @@ i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester i18n-embed-fl = "0.10" rust-embed = { version = "8.12", features = ["interpolate-folder-path"] } clap = { version = "4.6", features = ["derive", "wrap_help"] } +clap_complete = { version = "4.6", features = ["unstable-dynamic"] } ratatui = "0.30" crossterm = { version = "0.29", features = ["event-stream"] } opener = "0.8" diff --git a/rust/crates/cli/Cargo.toml b/rust/crates/cli/Cargo.toml index 0e49958791..c71555cfc7 100644 --- a/rust/crates/cli/Cargo.toml +++ b/rust/crates/cli/Cargo.toml @@ -20,6 +20,7 @@ ProductName = "Spicetify" spicetify = { path = "../spicetify" } tui = { path = "../tui" } clap = { workspace = true, features = ["derive"] } +clap_complete = { workspace = true } anyhow = { workspace = true } i18n-embed-fl = { workspace = true } diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 26c419bc19..0a09dc4c09 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -1,7 +1,8 @@ use std::io::{self, Write}; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::CompleteEnv; use i18n_embed_fl as _; use spicetify::commands::{Command, ConfigAction, DaemonAction, PkgAction, UpdatesAction}; use spicetify::{fl, logging}; @@ -171,6 +172,11 @@ impl From for PkgAction { } fn main() { + // Intercepts a `COMPLETE=` invocation to generate shell completions + // and exits, otherwise falls through to normal startup. + // Must run before locale::localize() to avoid unnecessary overhead on + // every tab-completion keystroke. + CompleteEnv::with_factory(SpicetifyCli::command).complete(); spicetify::locale::localize(); if let Err(err) = run() { eprintln!("{} {err:#}", fl!("fatal-prefix")); diff --git a/rust/crates/cli/tests/completions.rs b/rust/crates/cli/tests/completions.rs new file mode 100644 index 0000000000..d328f76bf6 --- /dev/null +++ b/rust/crates/cli/tests/completions.rs @@ -0,0 +1,25 @@ +#![allow(unused_crate_dependencies)] + +use std::process::Command; + +#[test] +fn completion_intercepts_arguments_for_every_supported_shell() { + for shell in ["bash", "zsh", "fish", "powershell", "elvish"] { + let output = Command::new(env!("CARGO_BIN_EXE_spicetify")) + .env("COMPLETE", shell) + .arg("--completion-smoke-test") + .output() + .expect("spicetify should run"); + + assert!( + output.status.success(), + "{shell} registration failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("spicetify"), + "{shell} registration did not name the command" + ); + assert!(output.stderr.is_empty(), "{shell} registration wrote to stderr"); + } +} diff --git a/scripts/test-install-completions.ps1 b/scripts/test-install-completions.ps1 new file mode 100644 index 0000000000..493e2dd768 --- /dev/null +++ b/scripts/test-install-completions.ps1 @@ -0,0 +1,29 @@ +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Installer = Get-Content -LiteralPath "$RepoRoot\install.ps1" -Raw +$Functions = $Installer.Substring(0, $Installer.IndexOf('#region Main')) +. ([scriptblock]::Create($Functions)) + +$TempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "spicetify-completion-$([guid]::NewGuid())" +$ProfilePath = Join-Path $TempDirectory 'profile.ps1' + +try { + New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null + [System.IO.File]::WriteAllText($ProfilePath, '# existing config') + + Add-SpicetifyCompletion -ProfilePath $ProfilePath + Add-SpicetifyCompletion -ProfilePath $ProfilePath + + $completion = '$env:COMPLETE = "powershell"; spicetify | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE' + $matches = @(Select-String -LiteralPath $ProfilePath -SimpleMatch $completion) + if ($matches.Count -ne 1) { + throw "Expected one completion line in $ProfilePath, found $($matches.Count)" + } + if ((Get-Content -LiteralPath $ProfilePath -First 1) -ne '# existing config') { + throw "Completion was not separated from existing content in $ProfilePath" + } +} +finally { + Remove-Item -LiteralPath $TempDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test-install-completions.sh b/scripts/test-install-completions.sh new file mode 100644 index 0000000000..06d7fda34c --- /dev/null +++ b/scripts/test-install-completions.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env sh +set -eu + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +temp_dir=$(mktemp -d) +trap 'rm -rf "$temp_dir"' EXIT + +helpers="$temp_dir/helpers.sh" +sed -n '/^# BEGIN shell completion helpers$/,/^# END shell completion helpers$/p' \ + "$repo_root/install.sh" > "$helpers" + +log() { :; } + +# shellcheck source=/dev/null +. "$helpers" + +assert_one_line() { + file=$1 + expected=$2 + actual=$(grep -Fxc "$expected" "$file" || true) + if [ "$actual" -ne 1 ]; then + echo "Expected one completion line in $file, found $actual" >&2 + exit 1 + fi +} + +run_case() { + shell_name=$1 + expected_file=$2 + expected_line=$3 + + case_home="$temp_dir/$shell_name" + config_file="$case_home/$expected_file" + mkdir -p "$(dirname "$config_file")" + printf '%s' '# existing config' > "$config_file" + ( + HOME=$case_home + SHELL="/bin/$shell_name" + XDG_CONFIG_HOME="$case_home/.config" + ZDOTDIR=$case_home + channel=v3 + export HOME SHELL XDG_CONFIG_HOME ZDOTDIR + install_shell_completion + install_shell_completion + ) + + assert_one_line "$case_home/$expected_file" "$expected_line" + if [ "$(sed -n '1p' "$config_file")" != '# existing config' ]; then + echo "Completion was not separated from existing content in $config_file" >&2 + exit 1 + fi +} + +run_case bash .bashrc 'source <(COMPLETE=bash spicetify)' +run_case zsh .zshrc 'source <(COMPLETE=zsh spicetify)' +run_case fish .config/fish/completions/spicetify.fish 'COMPLETE=fish spicetify | source' +run_case elvish .elvish/rc.elv 'eval (E:COMPLETE=elvish spicetify | slurp)' + +v2_home="$temp_dir/v2" +mkdir -p "$v2_home" +( + HOME=$v2_home + SHELL=/bin/zsh + channel=v2 + export HOME SHELL channel + install_shell_completion +) +if [ -e "$v2_home/.zshrc" ]; then + echo "v2 install unexpectedly configured v3 completion" >&2 + exit 1 +fi