diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index a1343b3..35a47b1 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -9,6 +9,7 @@ on: paths: - "zi.zsh" - "lib/**" + - "tests/annex-unregister.zsh" - "tests/archive-extraction.zsh" - "tests/completion-refresh.zsh" - "tests/hook-ownership.zsh" @@ -33,6 +34,7 @@ on: paths: - "zi.zsh" - "lib/**" + - "tests/annex-unregister.zsh" - "tests/archive-extraction.zsh" - "tests/completion-refresh.zsh" - "tests/hook-ownership.zsh" @@ -230,6 +232,17 @@ jobs: - name: Test scheduler idle behavior run: zsh -f tests/scheduler-idle.zsh + annex-unregister: + name: Annex Unregister + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test annex unregister + run: zsh -f tests/annex-unregister.zsh + archive-extraction: name: Archive extraction runs-on: ubuntu-latest diff --git a/tests/annex-unregister.zsh b/tests/annex-unregister.zsh new file mode 100644 index 0000000..bec1fb0 --- /dev/null +++ b/tests/annex-unregister.zsh @@ -0,0 +1,100 @@ +#!/usr/bin/env zsh +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +builtin emulate -R zsh +setopt pipe_fail + +fail() { + builtin print -u2 -r -- "not ok - $1" + exit 1 +} + +typeset project_root="${ZI_TEST_CHECKOUT:-${0:A:h:h}}" +typeset temp_root +temp_root="$(command mktemp -d "${TMPDIR:-/tmp}/zi-annex-unregister-test.XXXXXXXX")" || + fail "create temporary directory" +trap 'command rm -rf -- "$temp_root"' EXIT INT TERM + +command mkdir -p \ + "${temp_root}/home" \ + "${temp_root}/cache" \ + "${temp_root}/config" \ + "${temp_root}/data" \ + "${temp_root}/zdotdir" \ + "${temp_root}/first" \ + "${temp_root}/second" || fail "create isolated environment" + +builtin print -r -- 'builtin print -r -- first-loaded' \ + > "${temp_root}/first/first.plugin.zsh" || fail "write the first plug-in" +builtin print -r -- 'builtin print -r -- second-loaded' \ + > "${temp_root}/second/second.plugin.zsh" || fail "write the second plug-in" + +env \ + HOME="${temp_root}/home" \ + XDG_CACHE_HOME="${temp_root}/cache" \ + XDG_CONFIG_HOME="${temp_root}/config" \ + XDG_DATA_HOME="${temp_root}/data" \ + ZDOTDIR="${temp_root}/zdotdir" \ + ZI_TEST_CHECKOUT="$project_root" \ + ZI_TEST_ROOT="$temp_root" \ + zsh -f <<'ZSH' || fail "an unregistered annex hook still affects later plug-in loads" +builtin emulate -R zsh +setopt pipe_fail + +builtin source "${ZI_TEST_CHECKOUT}/zi.zsh" || return 1 +.zi-prepare-home || return 1 + +# Unregistering something never registered is a no-op, not an error. +@zi-unregister-annex never-registered hook:before-load-2 || { + builtin print -u2 -r -- "unregistering an absent annex reported an error" + return 1 +} + +probe_annex_handler() { return 0; } +@zi-register-annex probe-annex hook:before-load-2 probe_annex_handler '' '' + +# The Plugin Standard unload contract has the annex remove its own handler. +# Without a matching unregister the ZI_EXTS entry survives, the before-load +# dispatch calls a name that no longer exists, and the resulting 127 is folded +# into the return value and shifts the argument list for the rest of the +# session. +@zi-unregister-annex probe-annex hook:before-load-2 +unfunction probe_annex_handler + +typeset output status_first status_second +output="$(zi load "${ZI_TEST_ROOT}/first" 2>&1)" +status_first=$? +[[ $status_first -eq 0 ]] || { + builtin print -u2 -r -- "loading after unregister returned ${status_first}: ${output}" + return 1 +} +[[ $output == *first-loaded* ]] || { + builtin print -u2 -r -- "the first plug-in did not load: ${output}" + return 1 +} + +# A second load proves the argument list was not shifted by the first. +output="$(zi load "${ZI_TEST_ROOT}/second" 2>&1)" +status_second=$? +[[ $status_second -eq 0 ]] || { + builtin print -u2 -r -- "the second load returned ${status_second}: ${output}" + return 1 +} +[[ $output == *second-loaded* ]] || { + builtin print -u2 -r -- "the second plug-in did not load: ${output}" + return 1 +} + +# The registry itself no longer holds the entry. +typeset key found=0 +for key in "${(@k)ZI_EXTS}"; do + [[ ${ZI_EXTS[$key]} == *probe_annex_handler* ]] && found=1 +done +(( found == 0 )) || { + builtin print -u2 -r -- "a ZI_EXTS entry for the unregistered annex survived" + return 1 +} +ZSH + +builtin print -r -- "ok - an unregistered annex hook is removed and later plug-in loads are unaffected" diff --git a/zi.zsh b/zi.zsh index f81e5b5..2e8ae22 100644 --- a/zi.zsh +++ b/zi.zsh @@ -1283,6 +1283,49 @@ builtin setopt no_aliases ZI_EXTS[ice-mods]="${ZI_EXTS[ice-mods]}${icemods:+|}${(j:|:)${(@)${(@s:|:)icemods}/(#b)(#s)(?)/$index-$match[1]}}" } } # ]]] +# FUNCTION: @zi-unregister-annex. [[[ +# Removes the registrations made by @zi-register-annex and @zi-register-hook for +# one annex and one hook type, so an annex can remove its handler as the Zsh +# Plugin Standard unload contract expects without leaving Zi dispatching to a +# function that no longer exists. +# +# A stale registration is not inert. The before-load dispatch calls the stored +# handler unconditionally, a missing function returns 127, and `127 & 1' is true, +# so Zi folds the error into its return value and shifts its argument list on +# every later plug-in load in the session. +# +# Unregistering something that was never registered is a no-op. +# +# Note: the ice-modifier list each registration contributed to +# ZI_EXTS[ice-mods] and ZI_EXTS2[ice-mods] is not unwound. Registration appends +# to a joined string without recording which annex contributed which entry, so +# removing one annex's share would need that association to be recorded first. +# A leftover ice name is recognised but dispatches to nothing, which is +# harmless; the dispatch entry removed here is the part that corrupts loads. +@zi-unregister-annex() { + builtin emulate -LR zsh ${=${options[xtrace]:#off}:+-o xtrace} + builtin setopt extended_glob no_bang_hist typeset_silent + + local name="$1" type="$2" ___key + local -a ___arr ___doomed + + for ___key in "${(@k)ZI_EXTS}"; do + [[ $___key == (seqno|ice-mods) ]] && continue + ___arr=( "${(Q)${(z@)ZI_EXTS[$___key]}[@]}" ) + [[ ${___arr[3]} == $name && ${___arr[4]} == $type ]] && ___doomed+=( "$___key" ) + done + (( ${#___doomed} )) && unset "ZI_EXTS[${^___doomed[@]}]" + + ___doomed=( ) + for ___key in "${(@k)ZI_EXTS2}"; do + [[ $___key == (seqno|ice-mods) ]] && continue + ___arr=( "${(Q)${(z@)ZI_EXTS2[$___key]}[@]}" ) + [[ ${___arr[3]} == $name && ${___arr[4]} == $type ]] && ___doomed+=( "$___key" ) + done + (( ${#___doomed} )) && unset "ZI_EXTS2[${^___doomed[@]}]" + + return 0 +} # ]]] # FUNCTION: @zi-register-hook. [[[ # Registers the z-annex inside Zi. @zi-register-hook() {