Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [ ] Update the `[Unreleased]` compare link to the new tag.
- [ ] Create and push a signed `vX.Y.Z` tag from `master`.

## [0.4.1] - 2026-04-21

### Fixed

- Stop zsh completion from leaking unmatched glob patterns into the
user's prompt. The completer function now scopes `NULL_GLOB` via
`emulate -L zsh -o NULL_GLOB`, so globs evaluated by `_files`
internals or user zstyles (e.g. specs tagged `globbed-files`)
silently drop when they match nothing — fixing both the
`no matches found: *:globbed-files` error under the default
`NOMATCH`, and the subsequent `*(/)` / `*(-/)` residue that would
otherwise appear on the command line under `NO_NOMATCH` when
completing a directory-typed flag in a directory with no subdirs.
- Actually stop the `*(-/)` / `*(/)` glob-pattern residue that `--dir
<TAB>` in an empty directory would type into the prompt. The
initial switch to `NULL_GLOB` was the right idea but was defeated
by a function-scoped `setopt noglob` sitting on top of the `_files`
call: that option disabled globbing inside `_path_files` as well,
so its internal `tmp1=( $~tmp1 )` never expanded `*(-/)` and the
literal pattern was handed to `compadd` as a candidate. Replace
the option with a `noglob` *precommand modifier* on the `_files`
call so globs like `*(*)` still reach `_files` unexpanded while
its internals run with `NULL_GLOB` semantics as intended.
- Also enable `EXTENDED_GLOB` in the completer's `emulate -L zsh`
scope. zsh's own `_files` builds qualifier patterns like `*(#q-/)`
and uses `(#b)` backreferences internally; `emulate -L zsh` resets
to plain-zsh defaults (extended glob off), so `_files -/` would
emit `bad pattern: *(#q-/):globbed-files` on every TAB even once
the residue bug above was fixed.

## [0.4.0] - 2026-04-17

### Added
Expand Down Expand Up @@ -174,7 +204,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `run` alias binary for shorter invocation.
- Unified commands for task run/list, dependency install, clean, and exec.

[Unreleased]: https://github.com/kjanat/runner/compare/v0.4.0...HEAD
[Unreleased]: https://github.com/kjanat/runner/compare/v0.4.1...HEAD
[0.4.1]: https://github.com/kjanat/runner/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/kjanat/runner/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/kjanat/runner/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/kjanat/runner/compare/v0.2.1...v0.3.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "runner"
version = "0.4.0"
version = "0.4.1"
# tombi: lint.rules.unused-noqa.disabled = true
# tombi: lint.rules.deprecated.disabled = true
authors = ["Kaj Kowalski <info+runner@kajkowalski.nl>"]
Expand Down
29 changes: 22 additions & 7 deletions src/complete/grouped.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,18 @@
function _clap_dynamic_completer_{NAME}() {
# Reset to zsh defaults with local scope so caller-side settings
# (XTRACE, shwordsplit, nullglob, aliases, …) don't leak into us
# and our tracing doesn't bleed into their prompt.
emulate -L zsh
# and our tracing doesn't bleed into their prompt. `-o NULL_GLOB`
# silences "no matches found" errors that `_files` internals and
# user zstyles (e.g. specs tagged `globbed-files`) would otherwise
# raise into the prompt when an unquoted `*` fails to match — and,
# unlike `NO_NOMATCH`, it does so without leaving the literal glob
# (e.g. `*(/)` from `_files -/` in a dir with no subdirectories)
# as a candidate that then gets inserted into the command line.
# `-o EXTENDED_GLOB` is required because zsh's own `_files` builds
# qualifier patterns like `*(#q-/)` and uses `(#b)` backreferences
# internally — without extended glob, those raise
# `bad pattern: *(#q-/):globbed-files` the moment `_files -/` runs.
emulate -L zsh -o NULL_GLOB -o EXTENDED_GLOB

local __runner_idx=$(( CURRENT - 1 ))
local __runner_ifs=$'\n'
Expand All @@ -45,11 +55,16 @@ function _clap_dynamic_completer_{NAME}() {
local __runner_flags="${${__runner_raw[1]}#__CLAP_PATHFILES__}"
__runner_flags="${__runner_flags#$'\t'}"
if [[ -n "$__runner_flags" ]]; then
# Disable globbing so patterns like `*(*)` reach `_files`
# literally (it interprets them itself); `emulate -L zsh`
# at the top of this function restores the option on return.
setopt noglob
_files ${=__runner_flags}
# `noglob` as a PRECOMMAND modifier (not `setopt noglob`)
# keeps globs like `*(*)` from expanding at the call site so
# they reach `_files` literally, while leaving globbing ON
# inside `_files` itself — `_path_files` internally does
# `tmp1=( $~tmp1 )` to list directories, and a function-wide
# `setopt noglob` would leave that pattern (e.g. `*(-/)` from
# `_files -/` in a dir with no subdirectories) unexpanded and
# thus leaked as a candidate. `NULL_GLOB` from `emulate` above
# then silently drops the no-match case without residue.
noglob _files ${=__runner_flags}
else
_files
fi
Expand Down
67 changes: 66 additions & 1 deletion src/complete/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,9 @@ mod tests {

use clap::{Arg, Command, ValueHint};

use super::{detect_path_files_flags, strip_tag_prefix, zsh_files_flags};
use clap_complete::env::EnvCompleter as _;

use super::{GroupedZsh, detect_path_files_flags, strip_tag_prefix, zsh_files_flags};

fn dir_flag_cmd() -> Command {
Command::new("runner").arg(
Expand Down Expand Up @@ -558,4 +560,67 @@ mod tests {
fn strip_tag_prefix_returns_empty_for_bare_source() {
assert_eq!(strip_tag_prefix("package.json", "package.json"), "");
}

/// `_files` internals (and user zstyles keyed on the `globbed-files`
/// tag) evaluate specs containing unquoted `*`. Under zsh's default
/// `NOMATCH` behaviour those raise `no matches found: *:globbed-files`
/// into the user's prompt; under `NO_NOMATCH`, the unmatched pattern
/// (e.g. `*(/)` from `_files -/`) instead survives as a literal and
/// gets inserted into the command line. The completion function must
/// scope `NULL_GLOB` via `emulate -L zsh` so unmatched globs silently
/// drop out — no error, and no literal to leak.
///
/// `EXTENDED_GLOB` is required on top of `NULL_GLOB` because zsh's
/// own `_files` builds qualifier patterns like `*(#q-/)` and uses
/// `(#b)` backreferences internally. Without extended glob, running
/// `_files -/` raises `bad pattern: *(#q-/):globbed-files` —
/// `emulate -L zsh` strips `EXTENDED_GLOB` from the caller's shell
/// unless we explicitly opt back in.
#[test]
fn registration_script_uses_null_glob_and_extended_glob() {
let mut buf = Vec::new();
GroupedZsh
.write_registration("COMPLETE", "runner", "runner", "/bin/runner", &mut buf)
.expect("registration should succeed");
let script = String::from_utf8(buf).expect("script must be utf-8");
assert!(
script.contains("emulate -L zsh -o NULL_GLOB -o EXTENDED_GLOB"),
"completion function must enable both NULL_GLOB (for unmatched \
globs) and EXTENDED_GLOB (so `_files`'s `*(#q-/)` qualifier \
parses); got:\n{script}"
);
}

/// `setopt noglob` inside the function would disable globbing in
/// `_path_files`'s internal `tmp1=( $~tmp1 )` expansion as well,
/// leaving the directory-qualifier pattern (`*(-/)`) unexpanded
/// and thus leaked as a candidate — defeating the `NULL_GLOB` fix.
/// The `noglob` *precommand modifier* only suppresses glob expansion
/// on the arguments of the `_files` call, not its internals, so the
/// two must never be confused in this script.
#[test]
fn registration_script_uses_noglob_precommand_not_setopt() {
let mut buf = Vec::new();
GroupedZsh
.write_registration("COMPLETE", "runner", "runner", "/bin/runner", &mut buf)
.expect("registration should succeed");
let script = String::from_utf8(buf).expect("script must be utf-8");
assert!(
script.contains("noglob _files"),
"path-hint delegation must use the `noglob` precommand modifier \
on the `_files` call so `*(*)` reaches `_files` literally while \
`_path_files`'s internal globbing still runs; got:\n{script}"
);
let offending = script.lines().find(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.contains("setopt noglob")
});
assert!(
offending.is_none(),
"`setopt noglob` disables globbing function-wide and blocks \
`_path_files` from expanding `*(-/)`, causing the literal \
pattern to leak as a completion candidate; offending line: \
{offending:?}\n\nfull script:\n{script}"
);
}
}
Loading