diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de389d..8c2efda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.17] - 2026-05-31 + +### Fixed + +- **Git Bash: Ctrl+C is lost after an expansion (#7).** Under Git Bash + (cygwin/msys bash) the readline `bind -x` handler is invoked on top + of the cygwin signal layer. Spawning a Win32 `.exe` from inside that + handler — which is exactly what every `runex hook` call did at + trigger time — caused the very next `SIGINT` to be lost, so the + user's reflexive `Ctrl+C` after an unwanted expansion no longer + cleared the line buffer, and pressing `Enter` ran the stale + expanded command. Reproduced on Windows 11 + Git Bash 2.50 with + every abbreviation, regardless of cursor position or whether the + subprocess output was consumed via `$(...)` or a temp file. The + root cause is the spawn itself, not how the output is read. + + The bash integration cache now ships a **bake-mode dispatcher** + selected at source time by `case "${OSTYPE-}"`. Under + `msys*`/`cygwin*` the trigger handler resolves the abbreviation + from a static table baked into the cache file (associative + arrays for exact + condition + pattern rules, plus a tiny + pure-bash renderer for `{}` cursor placement and `{number}` + repetition). No subprocess is spawned, so the next `SIGINT` + reaches the shell as it should and `Ctrl+C` clears the line as + on every other platform. + +### Changed + +- **Shell taxonomy: `Shell::CygwinBash` variant dropped from the + plan.** The 0.1.16 CHANGELOG mentioned that 0.1.17 would + introduce a `Shell::CygwinBash` enum variant. After PoC we found + the difference is purely a runtime-environment quirk, not a + language-level shell distinction, and that taxonomy expansion + would have pushed across the enum, config, export, init, and + infra layers for a problem that fits in one runtime `case` + block. The shipping approach keeps `Shell` unchanged and routes + through `$OSTYPE` inside the cache file instead. Linux bash, + WSL bash, zsh, pwsh, and nu users see no change. + +- **Bash integration cache version bumped 1 → 2.** Caches written + by 0.1.16 still source cleanly under 0.1.17 (the legacy exec + path is still the `*)` arm of the `case`), but `runex doctor` + now flags v1 caches as stale so users get nudged into + `runex init bash` to pick up the bake dispatcher on Git Bash. + +### Known interim trade-off (tracked for closure in 0.1.18) + +- **Git Bash only: argument-position tokens also expand in 0.1.17.** + The exec-path hook understood that `echo gst` does not expand + `gst` because it is in argument position, not command position. + The 0.1.17 bake path skips that check — re-implementing the + state machine in pure bash is straightforward but adds enough + surface that it was carved out into 0.1.18 so the Ctrl+C fix + could ship first. Until 0.1.18 lands, the bake path expands + any trailing token that matches an abbreviation regardless of + the preceding word. Note that `docs/recipes.md`'s explicit + command-position rules (`sudo gst`, tokens after `|`/`||`/ + `&&`/`;`) keep working on both paths because those positions + *are* command positions. Documented in `docs/setup.{md,ja.md}` + and pinned by a regression test + (`tests/bash_cygwin_bake_pty.rs::cygwin_bake_expands_even_when_token_is_not_in_command_position`) + so the 0.1.18 fix is a deliberate behaviour change, not a + stealth regression. Workarounds while 0.1.17 is current: + quote literals you don't want expanded (`echo "gst"`) or pick + abbreviation keys that won't collide with English words. Every + other shell (including Linux bash and WSL bash) retains full + command-position detection. + ## [0.1.16] - 2026-05-23 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a840e61..031ffa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -327,6 +327,46 @@ https://github.com/ShortArrow/runex/releases/tag/vX.Y.Z. to "literal space" when `runex hook` errors out as an unknown subcommand, and it's hard to debug from the user side. Don't skip. + The helper edits `packaging/aur-bin/PKGBUILD` and writes + `packaging/aur-bin/.SRCINFO` **in this repo as well** (the AUR + clone copy is taken from those). Commit the in-repo changes as + part of the back-merge step so the template stays in sync with + the published AUR version — otherwise the in-repo PKGBUILD + drifts (it stayed pinned at 0.1.11 across the 0.1.12–0.1.14 + cycle before this rule was added). + +- [ ] **AUR `runex` (source).** Sibling of `runex-bin` (binary); + this package builds from the `runex` crate published to crates.io + by `release.yml`. It is **not interchangeable** with `runex-bin` + — users pick one, and the PKGBUILD declares + `conflicts=('runex-bin')` (and `runex-bin` declares + `conflicts=('runex')`) to enforce that. Use the helper: + + ```bash + packaging/aur/release.sh X.Y.Z ~/aur/runex + ``` + + Same sha-fetch + PKGBUILD + .SRCINFO + local-commit flow as + `runex-bin`, but the source is the `.crate` from crates.io — + so the script must run **after** the crates.io publish completes + (= same gate as Homebrew). Push manually: + + ```bash + cd ~/aur/runex + GIT_SSH_COMMAND='ssh -i ~/.ssh/aur' git push origin master + ``` + + The helper also edits `packaging/aur/PKGBUILD` and writes + `packaging/aur/.SRCINFO` in this repo. Commit those in the + back-merge step alongside the `runex-bin` updates — same rule, + same reason (the in-repo template stays the source of truth so + it doesn't drift like `runex-bin`'s did across 0.1.12–0.1.14). + + Historical note: `runex` was a third-party AUR package + (maintainer: Rafael Dominiquini) through 0.1.16. Maintainership + was transferred to ShortArrow on 2026-05-25. Dominiquini is + preserved as `Contributor:` in the PKGBUILD header. + - [ ] **Homebrew tap.** Use the helper: ```bash diff --git a/Cargo.lock b/Cargo.lock index ca33ba1..30fd001 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,7 +552,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "runex" -version = "0.1.16" +version = "0.1.17" dependencies = [ "base64", "clap", diff --git a/docs/setup.ja.md b/docs/setup.ja.md index e198967..7c65ac8 100644 --- a/docs/setup.ja.md +++ b/docs/setup.ja.md @@ -212,4 +212,4 @@ $ runex doctor 4. **clink: `lsd` がインストール済なのに `command:lsd not found`**: clink-injection された cmd の PATH が User-scope のレジストリエントリを欠いている可能性があります。runex は Windows ではレジストリでコマンド解決を補強しますが、HKCU/HKLM の `Environment\Path` どちらにも入っていないディレクトリを使っている場合は、いずれかに追加する必要があります (`RUNEX_CLINK_LUA_PATH` で lua ファイル自体の場所を上書きすることも可能)。 5. **pwsh: marker found なのに Space が反応しない**: ほぼ次の 3 つのどれかです。(a) PSReadLine が load されていない — `Get-Module PSReadLine` で行が出るか確認、出なければインストール or import。(b) Windows PowerShell 5 の実行ポリシーが `Restricted` でキャッシュファイルの dot-source が拒否されている — `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` で解消。(c) `AllSigned` ポリシーで `Documents\PowerShell\Modules` 配下の新しい PSReadLine が拒否されている — 一度 `[A] Always run` を選ぶか `$env:PSModulePath` から該当ディレクトリを除外。[PowerShell セットアップセクション](#powershell) に詳細コマンドあり。 6. **pwsh: キャッシュヘッダが二重 / `__runex_queued_key_count` が認識されない**: キャッシュファイルに `# runex-integration-version` ヘッダが 2 段ある状態 (例: 何かが `runex export pwsh` の出力に独自ヘッダを連結した結果)。キャッシュの interactive guard が function 定義より前で評価されるため、不正なキャッシュは function 定義をスキップします。`runex init pwsh` を再実行してキャッシュを書き直してください。 -7. **Git Bash: `{}` プレースホルダ展開後に Ctrl+C で行が消えない** (cygwin/msys readline の制約)。Windows の Git Bash 特有の問題で、`expand` に `{}` を含む abbreviation を展開した直後 (カーソルが行の中間で止まる状態) に `Ctrl+C` を押しても line buffer がクリアされません。続けて `Enter` を押すと、展開済みのコマンド (例: 空の `git commit -am ''`) がそのまま実行されます。Linux bash / WSL bash / zsh / pwsh / nu では正しく動作し、Git Bash の cygwin readline backend だけがこの挙動を示します。回避策: `Ctrl+C` の前に `Backspace` (または何か 1 文字) を入力する、または行全体を手で消す。runex 0.1.16 で cygwin/msys bash を独立した shell variant として認識し、template 側で workaround を入れる予定です。 +7. **Git Bash: 0.1.17 暫定 trade-off — argument 位置の token も展開される**: Git Bash 環境では runex 0.1.17 は cache file 内の静的な abbreviation table を直接参照する *bake mode* dispatcher に切り替わり、trigger 押下時に Windows の `runex` バイナリを spawn しません。これは 0.1.16 の Ctrl+C bug (cygwin の readline が `bind -x` 内の Win32 process spawn 後に次の SIGINT を失っていた) を根絶するためです。0.1.17 dispatcher は trailing token がいずれかの abbreviation key と一致すれば argument 位置でも展開します。例: Linux bash では `echo gst` の `gst` は command 位置でないので展開されませんが、Git Bash では展開されます。`docs/recipes.md` の command-position ルール (`sudo gst`、`|`/`||`/`&&`/`;` の後の ``) はそれらの位置が **元々 command 位置である** ため両 path で従来通り動作します。0.1.17 が現行版である間の回避策はリテラルを引用符で囲む (`echo "gst"`) か、英単語と衝突しない key を選ぶこと。**0.1.18 で解消予定**: pure bash で command-position 判定を再実装し、bake path を exec path と完全一致させます。Linux bash / WSL bash / zsh / pwsh / nu では従来通り runtime hook + 完全な command-position 判定が動きます。 diff --git a/docs/setup.md b/docs/setup.md index 7f3866b..df66109 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -313,15 +313,23 @@ If a trigger key produces a literal space instead of expanding: output with its own header). The cache's interactive guard runs before the function definitions, so a malformed cache silently skips them. Re-run `runex init pwsh` to rewrite the cache cleanly. -7. **Git Bash: Ctrl+C does not clear the line after a `{}`-placeholder - expansion** (cygwin/msys readline limitation). On Git Bash specifically, - pressing `Ctrl+C` immediately after expanding an abbreviation whose - `expand` contained `{}` (so the cursor lands in the middle of the - line) does **not** clear the line buffer. A subsequent `Enter` then - runs the stale expanded command, e.g. an empty `git commit -am ''`. - The same flow works correctly on Linux bash, WSL bash, zsh, pwsh, - and nu — only Git Bash's cygwin readline is affected. Workaround: - press `Backspace` (or any character key) before `Ctrl+C`, or just - delete the line manually. Runex 0.1.16 will treat cygwin/msys bash - as a distinct shell variant so the bash template can apply a - workaround tailored to that backend. +7. **Git Bash: 0.1.17 interim trade-off — argument-position tokens + also expand.** On Git Bash specifically, runex 0.1.17 switches + to a *bake-mode* dispatcher that looks expansions up from a + static table baked into the cache file, never spawning the + `runex` Windows binary from inside the trigger handler. That is + what fixes the 0.1.16 Ctrl+C-after-expansion bug (cygwin + readline lost the next SIGINT whenever a `bind -x` handler + spawned a Win32 process). The 0.1.17 dispatcher expands any + trailing token that matches an abbreviation, including in + argument position — e.g. `echo gst` expands `gst` on + Git Bash even though Linux bash leaves it alone. The + command-position rules from `docs/recipes.md` (`sudo gst`, + `` after `|` / `||` / `&&` / `;`) still hold on both + paths because those positions *are* command positions. + Workarounds while 0.1.17 is current: quote the literal + (`echo "gst"`) or pick abbreviation keys that won't collide + with English words. **Tracked for closure in 0.1.18**: + re-implementing command-position detection in pure bash so the + bake path matches the exec path. Linux bash, WSL bash, zsh, + pwsh, and nu retain full command-position detection. diff --git a/packaging/aur-bin/.SRCINFO b/packaging/aur-bin/.SRCINFO index 6b1e992..bf9dea7 100644 --- a/packaging/aur-bin/.SRCINFO +++ b/packaging/aur-bin/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = runex-bin pkgdesc = Cross-shell abbreviation engine that expands short tokens into full commands - pkgver = 0.1.11 + pkgver = 0.1.16 pkgrel = 1 url = https://github.com/ShortArrow/runex arch = x86_64 @@ -9,13 +9,13 @@ pkgbase = runex-bin license = Apache-2.0 provides = runex conflicts = runex - source = LICENSE-0.1.11::https://raw.githubusercontent.com/ShortArrow/runex/v0.1.11/LICENSE - source = README-0.1.11.md::https://raw.githubusercontent.com/ShortArrow/runex/v0.1.11/README.md - sha256sums = 19614dcc7dc2af82331a66d30ab79d5674be3ce73ef853ed76e1743b2830f4d0 - sha256sums = b5d6502f8cf0088b3d75cc79a1fad78ca7da3374803a49e70b7709f991410439 - source_x86_64 = runex-0.1.11-x86_64.tar.gz::https://github.com/ShortArrow/runex/releases/download/v0.1.11/runex-x86_64-unknown-linux-gnu.tar.gz - sha256sums_x86_64 = 1d2767e51ae575739ef97a048a27334c3d7c8b46550cd5ee400c85444b91e5fe - source_aarch64 = runex-0.1.11-aarch64.tar.gz::https://github.com/ShortArrow/runex/releases/download/v0.1.11/runex-aarch64-unknown-linux-gnu.tar.gz - sha256sums_aarch64 = 14f94242f7d151158ffaefbf90541e78d828d620b3a0588a8bbac6c2d9361422 + source = LICENSE-0.1.16::https://raw.githubusercontent.com/ShortArrow/runex/v0.1.16/LICENSE + source = README-0.1.16.md::https://raw.githubusercontent.com/ShortArrow/runex/v0.1.16/README.md + sha256sums = 735fa89d57bbf22a8c85d829aa1ed791cce81ffdb900467333025ab7b2feee1c + sha256sums = 6841cfc9dce7aabf01df2cfc36c8f9ee030c16eae6e50f877f8311538c735942 + source_x86_64 = runex-0.1.16-x86_64.tar.gz::https://github.com/ShortArrow/runex/releases/download/v0.1.16/runex-x86_64-unknown-linux-gnu.tar.gz + sha256sums_x86_64 = 3857d2ded00a0c5b2d8c18e6357aea6e6f2c07854cf5c2a28a79ec3d5a4d16b7 + source_aarch64 = runex-0.1.16-aarch64.tar.gz::https://github.com/ShortArrow/runex/releases/download/v0.1.16/runex-aarch64-unknown-linux-gnu.tar.gz + sha256sums_aarch64 = 171f1cd715f0420c6058769d45c6dcdcbbda0f6eae65cfd947c34e8995255858 pkgname = runex-bin diff --git a/packaging/aur-bin/PKGBUILD b/packaging/aur-bin/PKGBUILD index 1177b1a..745348a 100644 --- a/packaging/aur-bin/PKGBUILD +++ b/packaging/aur-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: ShortArrow pkgname=runex-bin -pkgver=0.1.11 +pkgver=0.1.16 pkgrel=1 pkgdesc="Cross-shell abbreviation engine that expands short tokens into full commands" arch=('x86_64' 'aarch64') @@ -12,10 +12,10 @@ source_x86_64=("runex-${pkgver}-x86_64.tar.gz::https://github.com/ShortArrow/run source_aarch64=("runex-${pkgver}-aarch64.tar.gz::https://github.com/ShortArrow/runex/releases/download/v${pkgver}/runex-aarch64-unknown-linux-gnu.tar.gz") source=("LICENSE-${pkgver}::https://raw.githubusercontent.com/ShortArrow/runex/v${pkgver}/LICENSE" "README-${pkgver}.md::https://raw.githubusercontent.com/ShortArrow/runex/v${pkgver}/README.md") -sha256sums_x86_64=('1d2767e51ae575739ef97a048a27334c3d7c8b46550cd5ee400c85444b91e5fe') -sha256sums_aarch64=('14f94242f7d151158ffaefbf90541e78d828d620b3a0588a8bbac6c2d9361422') -sha256sums=('19614dcc7dc2af82331a66d30ab79d5674be3ce73ef853ed76e1743b2830f4d0' - 'b5d6502f8cf0088b3d75cc79a1fad78ca7da3374803a49e70b7709f991410439') +sha256sums_x86_64=('3857d2ded00a0c5b2d8c18e6357aea6e6f2c07854cf5c2a28a79ec3d5a4d16b7') +sha256sums_aarch64=('171f1cd715f0420c6058769d45c6dcdcbbda0f6eae65cfd947c34e8995255858') +sha256sums=('735fa89d57bbf22a8c85d829aa1ed791cce81ffdb900467333025ab7b2feee1c' + '6841cfc9dce7aabf01df2cfc36c8f9ee030c16eae6e50f877f8311538c735942') package() { install -Dm755 "${srcdir}/runex" "${pkgdir}/usr/bin/runex" diff --git a/packaging/aur-bin/release.sh b/packaging/aur-bin/release.sh old mode 100644 new mode 100755 diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO new file mode 100644 index 0000000..0bc276c --- /dev/null +++ b/packaging/aur/.SRCINFO @@ -0,0 +1,22 @@ +pkgbase = runex + pkgdesc = Cross-shell abbreviation engine that expands short tokens into full commands + pkgver = 0.1.16 + pkgrel = 1 + url = https://github.com/ShortArrow/runex + arch = x86_64 + arch = aarch64 + license = MIT + license = Apache-2.0 + makedepends = rust + makedepends = pkgconf + depends = glibc + depends = libgcc + depends = openssl + provides = runex + conflicts = runex-bin + source = runex-0.1.16.crate::https://crates.io/api/v1/crates/runex/0.1.16/download + source = LICENSE + sha256sums = 0c13a94189c9a38430c940488bf4f99df8d4cdae129d0b8cf7035beae5e2a958 + sha256sums = 735fa89d57bbf22a8c85d829aa1ed791cce81ffdb900467333025ab7b2feee1c + +pkgname = runex diff --git a/packaging/aur/.nvchecker.toml b/packaging/aur/.nvchecker.toml new file mode 100644 index 0000000..5bc2825 --- /dev/null +++ b/packaging/aur/.nvchecker.toml @@ -0,0 +1,3 @@ +[runex] +source = "cratesio" +cratesio = "runex" diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 0000000..2c88e2b --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,48 @@ +# Maintainer: ShortArrow +# Contributor: Rafael Dominiquini + +_pkgauthor=ShortArrow +_pkgname=runex +_cratename=${_pkgname} +_appname=runex +pkgname=${_cratename} +pkgdesc="Cross-shell abbreviation engine that expands short tokens into full commands" + +pkgver=0.1.16 +pkgrel=1 +_pkgvername=${pkgver} + +arch=('x86_64' 'aarch64') +_barch=('x86_64' 'aarch64') + +url="https://github.com/${_pkgauthor}/${_pkgname}" + +license=('MIT' 'Apache-2.0') + +makedepends=('rust' 'pkgconf') +depends=('glibc' 'libgcc' 'openssl') + +provides=("${_appname}") +conflicts=('runex-bin') + +source=("${_pkgname}-${_pkgvername}.crate::https://crates.io/api/v1/crates/${_cratename}/${_pkgvername}/download" + "LICENSE") +sha256sums=('0c13a94189c9a38430c940488bf4f99df8d4cdae129d0b8cf7035beae5e2a958' + '735fa89d57bbf22a8c85d829aa1ed791cce81ffdb900467333025ab7b2feee1c') + + +build() { + cd ${srcdir}/${_cratename}-${_pkgvername} || exit 1 + + RUSTFLAGS="--remap-path-prefix=$(pwd)=/build/" cargo build --release --locked +} + +package() { + cd ${srcdir}/${_cratename}-${_pkgvername} || exit 1 + + install -Dm755 "target/release/${_appname}" "${pkgdir}/usr/bin/${_appname}" + + install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md" + + install -Dm644 "../LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/aur/release.sh b/packaging/aur/release.sh new file mode 100755 index 0000000..b29a22e --- /dev/null +++ b/packaging/aur/release.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Update the AUR `runex` (source build) package to a given version. +# +# Sibling of packaging/aur-bin/release.sh, but the source is the +# `.crate` published to crates.io by release.yml's publish-crates job +# rather than a GitHub Release tarball. Must therefore run AFTER the +# tag push has reached crates.io (same gate as Homebrew). +# +# Usage: +# packaging/aur/release.sh [] +# +# Example: +# packaging/aur/release.sh 0.1.17 ~/aur/runex +# +# What it does: +# 1. Fetches SHA256 of the crates.io `.crate` and LICENSE +# 2. Rewrites pkgver + sha256sums in PKGBUILD +# 3. Regenerates .SRCINFO via `makepkg --printsrcinfo` +# 4. Copies PKGBUILD and .SRCINFO into the AUR working clone +# 5. Commits and prints the `git push` command (does NOT push automatically) + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 []" >&2 + exit 1 +fi + +VERSION="$1" +AUR_REPO="${2:-$HOME/aur/runex}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKGBUILD="$SCRIPT_DIR/PKGBUILD" + +if [[ ! -f "$PKGBUILD" ]]; then + echo "PKGBUILD not found: $PKGBUILD" >&2 + exit 1 +fi + +if [[ ! -d "$AUR_REPO/.git" ]]; then + echo "AUR clone not found: $AUR_REPO" >&2 + echo "Run: git clone ssh://aur@aur.archlinux.org/runex.git $AUR_REPO" >&2 + exit 1 +fi + +sha_of() { + local url="$1" + curl -fsSL "$url" | sha256sum | awk '{print $1}' +} + +CRATE_URL="https://crates.io/api/v1/crates/runex/${VERSION}/download" +RAW="https://raw.githubusercontent.com/ShortArrow/runex/v${VERSION}" + +# LICENSE is bundled with the AUR clone (referenced as a bare `LICENSE` +# entry in PKGBUILD's source=), so its sha must match the actual file +# we copy in. We pull it from the in-repo LICENSE rather than from the +# tag's raw URL — that way the runex repo is the single source of +# truth and a missed v-tag push doesn't desync the AUR build. +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +REPO_LICENSE="$REPO_ROOT/LICENSE" +if [[ ! -f "$REPO_LICENSE" ]]; then + echo "Repo-root LICENSE not found: $REPO_LICENSE" >&2 + exit 1 +fi + +echo "Fetching SHA256 checksums for v${VERSION}..." +SHA_CRATE=$(sha_of "$CRATE_URL") +SHA_LIC=$(sha256sum "$REPO_LICENSE" | awk '{print $1}') + +echo " crate: $SHA_CRATE" +echo " LICENSE: $SHA_LIC (from repo root LICENSE)" + +sed -i \ + -e "s/^pkgver=.*/pkgver=${VERSION}/" \ + -e "s/^pkgrel=.*/pkgrel=1/" \ + "$PKGBUILD" + +# Rewrite the single multi-line sha256sums=(...) block. The crate is the +# first entry and LICENSE is the second; that order matches the `source=` +# block above it. Whitespace inside the parens is preserved-ish (we +# re-emit with the same 12-space continuation that Dominiquini's original +# template used) so a downstream diff stays small. +python3 - "$PKGBUILD" "$SHA_CRATE" "$SHA_LIC" <<'PY' +import re, sys +path, sha_crate, sha_lic = sys.argv[1:4] +src = open(path).read() +pattern = re.compile( + r"sha256sums=\(\s*'[0-9a-f]{64}'\s*\n\s*'[0-9a-f]{64}'\s*\)", + re.M, +) +replacement = f"sha256sums=('{sha_crate}'\n '{sha_lic}')" +new, n = pattern.subn(replacement, src, count=1) +if n != 1: + raise SystemExit(f"failed to rewrite sha256sums block in {path} (matched {n} times)") +open(path, "w").write(new) +PY + +echo "Regenerating .SRCINFO..." +(cd "$SCRIPT_DIR" && makepkg --printsrcinfo > .SRCINFO) + +echo "Copying PKGBUILD, .SRCINFO, .nvchecker.toml, and LICENSE into $AUR_REPO..." +cp "$SCRIPT_DIR/PKGBUILD" "$AUR_REPO/PKGBUILD" +cp "$SCRIPT_DIR/.SRCINFO" "$AUR_REPO/.SRCINFO" +cp "$SCRIPT_DIR/.nvchecker.toml" "$AUR_REPO/.nvchecker.toml" +cp "$REPO_LICENSE" "$AUR_REPO/LICENSE" + +(cd "$AUR_REPO" && git add PKGBUILD .SRCINFO .nvchecker.toml LICENSE && git commit -m "Update to ${VERSION}") + +cat <) -> Config { + Config { + version: 1, + keybind: KeybindConfig::default(), + precache: PrecacheConfig::default(), + abbr, + } + } + + fn plain_abbr(key: &str, expand: &str) -> Abbr { + Abbr { + key: key.into(), + expand: PerShellString::All(expand.into()), + when_command_exists: None, + number: None, + } + } + + #[test] + fn exact_table_lines_emits_one_entry_per_plain_abbr() { + let c = cfg(vec![ + plain_abbr("gst", "git status"), + plain_abbr("gcm", "git commit -m"), + ]); + let s = exact_table_lines(&c); + assert!(s.contains("[\"gst\"]=\"git status\""), "got: {s}"); + assert!(s.contains("[\"gcm\"]=\"git commit -m\""), "got: {s}"); + } + + #[test] + fn exact_table_lines_excludes_pattern_keys() { + // `{number}` keys go into the pattern table instead. + let mut up = plain_abbr("up{number}", "cd {number}"); + up.number = Some("../".into()); + let c = cfg(vec![plain_abbr("gst", "git status"), up]); + let s = exact_table_lines(&c); + assert!(s.contains("[\"gst\"]"), "exact table should keep gst: {s}"); + assert!(!s.contains("up{number}"), "exact table should drop pattern keys: {s}"); + } + + #[test] + fn exact_table_lines_excludes_cursor_placeholder_in_key_position_safely() { + // `{}` cursor placeholder belongs to expand text, not keys. + // The key filter rejects any `{`, which includes the unlikely + // case of a `{}` literal in the key. Validator already rejects + // that, but the filter is the line of defence. + let mut bad = plain_abbr("ok", "ok"); + bad.key = "bad{}key".into(); + let c = cfg(vec![plain_abbr("gst", "git status"), bad]); + let s = exact_table_lines(&c); + assert!(s.contains("[\"gst\"]"), "got: {s}"); + assert!(!s.contains("bad{}key"), "got: {s}"); + } + + #[test] + fn exact_table_lines_uses_bash_specific_expand_value_when_bound() { + let a = Abbr { + key: "open".into(), + expand: PerShellString::ByShell { + default: Some("xdg-open".into()), + bash: Some("xdg-open --wait".into()), + zsh: None, pwsh: None, nu: None, + }, + when_command_exists: None, + number: None, + }; + let s = exact_table_lines(&cfg(vec![a])); + assert!(s.contains("[\"open\"]=\"xdg-open --wait\""), "got: {s}"); + } + + #[test] + fn exact_table_lines_skips_rules_without_bash_expand_value() { + // `default = None` + bash = None → for_shell(Bash) returns None + // and the rule contributes nothing to the bake table. + let a = Abbr { + key: "winonly".into(), + expand: PerShellString::ByShell { + default: None, + bash: None, + zsh: None, + pwsh: Some("Get-Process".into()), + nu: None, + }, + when_command_exists: None, + number: None, + }; + let s = exact_table_lines(&cfg(vec![a, plain_abbr("gst", "git status")])); + assert!(!s.contains("winonly"), "got: {s}"); + assert!(s.contains("[\"gst\"]"), "got: {s}"); + } + + #[test] + fn exact_table_lines_indents_with_four_spaces() { + // Cache file readability: every entry indented for inclusion + // inside the `declare -gA __runex_abbr_expand=(...)` block. + let s = exact_table_lines(&cfg(vec![plain_abbr("gst", "git status")])); + assert!(s.starts_with(" "), "expected four-space indent, got: {s:?}"); + } + + #[test] + fn exact_table_lines_empty_for_empty_config() { + let s = exact_table_lines(&cfg(vec![])); + assert_eq!(s, ""); + } + + // ── cond_table_lines ─────────────────────────────────────────────── + + use crate::domain::model::PerShellCmds; + + fn abbr_with_when_cmds(key: &str, expand: &str, cmds: Vec<&str>) -> Abbr { + Abbr { + key: key.into(), + expand: PerShellString::All(expand.into()), + when_command_exists: Some(PerShellCmds::All( + cmds.into_iter().map(String::from).collect(), + )), + number: None, + } + } + + #[test] + fn cond_table_lines_emits_entry_for_single_command_guard() { + let c = cfg(vec![abbr_with_when_cmds("ls", "lsd", vec!["lsd"])]); + let s = cond_table_lines(&c); + assert!(s.contains("[\"ls\"]=\"lsd\""), "got: {s}"); + } + + #[test] + fn cond_table_lines_joins_multi_command_guard_with_colon() { + // `:` is the conventional bash IFS for PATH-style lists and never + // appears in a command name, so it's the safest delim for splitting + // back in the bake dispatcher. + let c = cfg(vec![abbr_with_when_cmds( + "ks", + "kubectl get pods", + vec!["kubectl", "stern"], + )]); + let s = cond_table_lines(&c); + assert!(s.contains("[\"ks\"]=\"kubectl:stern\""), "got: {s}"); + } + + #[test] + fn cond_table_lines_skips_rules_without_when_command_exists() { + let c = cfg(vec![ + plain_abbr("gst", "git status"), + abbr_with_when_cmds("ls", "lsd", vec!["lsd"]), + ]); + let s = cond_table_lines(&c); + assert!(s.contains("[\"ls\"]"), "got: {s}"); + assert!(!s.contains("[\"gst\"]"), "cond table must not list unguarded rules: {s}"); + } + + #[test] + fn cond_table_lines_uses_bash_specific_when_command_exists_value() { + let a = Abbr { + key: "open".into(), + expand: PerShellString::All("xdg-open".into()), + when_command_exists: Some(PerShellCmds::ByShell { + default: Some(vec!["open".into()]), + bash: Some(vec!["xdg-open".into()]), + zsh: None, pwsh: None, nu: None, + }), + number: None, + }; + let s = cond_table_lines(&cfg(vec![a])); + assert!(s.contains("[\"open\"]=\"xdg-open\""), "got: {s}"); + } + + #[test] + fn cond_table_lines_skips_empty_command_list() { + // Defensive: an empty list would map to an empty string in the + // bake table and bash's `for c in $conds` would do nothing, + // which is correct but wastes a line in the cache file. + let c = cfg(vec![abbr_with_when_cmds("nope", "noop", vec![])]); + let s = cond_table_lines(&c); + assert_eq!(s, ""); + } + + #[test] + fn cond_table_lines_excludes_pattern_keys() { + // Pattern keys (`{number}`) live in the pattern table, which has + // its own condition handling. Don't double-list them here. + let mut up = abbr_with_when_cmds("up{number}", "cd {number}", vec!["pushd"]); + up.number = Some("../".into()); + let s = cond_table_lines(&cfg(vec![up])); + assert_eq!(s, ""); + } + + // ── pattern_table_lines ──────────────────────────────────────────── + + fn pattern_abbr(key: &str, expand: &str, unit: &str) -> Abbr { + Abbr { + key: key.into(), + expand: PerShellString::All(expand.into()), + when_command_exists: None, + number: Some(unit.into()), + } + } + + #[test] + fn pattern_table_lines_emits_entry_with_prefix_suffix_template_unit() { + // `up{number}` → prefix="up", suffix="", template="cd {number}", unit="../" + let c = cfg(vec![pattern_abbr("up{number}", "cd {number}", "../")]); + let s = pattern_table_lines(&c); + // Field separator is bash ANSI-C-quoted US (\037). The four-space + // indent matches the array-entry convention used elsewhere. + assert!( + s.contains("\"up\"$'\\037'\"\"$'\\037'\"cd {number}\"$'\\037'\"../\""), + "got: {s}" + ); + assert!(s.starts_with(" "), "expected four-space indent, got: {s:?}"); + } + + #[test] + fn pattern_table_lines_handles_prefix_and_suffix() { + // `g{number}p` → prefix="g", suffix="p" + let c = cfg(vec![pattern_abbr("g{number}p", "git push -n {number}", "x")]); + let s = pattern_table_lines(&c); + assert!( + s.contains("\"g\"$'\\037'\"p\"$'\\037'\"git push -n {number}\"$'\\037'\"x\""), + "got: {s}" + ); + } + + #[test] + fn pattern_table_lines_skips_rules_without_number_unit() { + // Without a number unit the pattern can't be repeated, so the + // rule is invalid at validation time; we skip defensively even + // if the validator missed it. + let no_unit = Abbr { + key: "up{number}".into(), + expand: PerShellString::All("cd {number}".into()), + when_command_exists: None, + number: None, + }; + let s = pattern_table_lines(&cfg(vec![no_unit])); + assert_eq!(s, ""); + } + + #[test] + fn pattern_table_lines_skips_rules_without_number_placeholder_in_key() { + // `number` set but no `{number}` in key — also invalid, skip. + let weird = Abbr { + key: "up".into(), + expand: PerShellString::All("cd".into()), + when_command_exists: None, + number: Some("../".into()), + }; + let s = pattern_table_lines(&cfg(vec![weird])); + assert_eq!(s, ""); + } + + #[test] + fn pattern_table_lines_skips_rules_without_bash_expand_value() { + let a = Abbr { + key: "up{number}".into(), + expand: PerShellString::ByShell { + default: None, + bash: None, + zsh: None, + pwsh: Some("Set-Location ..".into()), + nu: None, + }, + when_command_exists: None, + number: Some("../".into()), + }; + let s = pattern_table_lines(&cfg(vec![a])); + assert_eq!(s, ""); + } + + #[test] + fn pattern_table_lines_empty_for_empty_config() { + assert_eq!(pattern_table_lines(&cfg(vec![])), ""); + } +} + +/// Wrap `s` as a bash double-quoted string suitable for embedding inside +/// an associative-array initializer like `["key"]="value"`. +/// +/// Escapes the four characters that bash interprets inside a +/// double-quoted string (`"`, `\`, `$`, `` ` ``) so the value survives +/// as literal bytes. Single quotes are left alone — they are literal +/// inside double quotes and the `{}` placeholder is frequently embedded +/// inside `'...'` argument quoting. +/// +/// ASCII control characters and deceptive Unicode are silently dropped, +/// matching the policy of [`crate::domain::shell::bash_quote_string`]. +fn bash_double_quote_for_assoc(s: &str) -> String { + use crate::domain::sanitize::{is_deceptive_unicode, is_unicode_line_separator}; + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '$' => out.push_str("\\$"), + '`' => out.push_str("\\`"), + c if c.is_ascii_control() => {} + c if is_unicode_line_separator(c) => {} + c if is_deceptive_unicode(c) => {} + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Build the `__runex_abbr_expand` associative-array body for the bake +/// dispatcher: one ` ["key"]="expand"` line per non-pattern rule. +/// +/// Rules whose key contains `{` are skipped — they are pattern rules and +/// are handled by [`pattern_table_lines`] further down. Rules without a +/// bash-applicable expansion (e.g. `pwsh`-only `ByShell` with no +/// `default`) are dropped silently; the user already validated that the +/// config makes sense for the shells they care about, and dropping is the +/// only response that keeps the bake path bytewise equivalent to the +/// exec path for bash. +fn exact_table_lines(config: &Config) -> String { + let mut lines = Vec::new(); + for rule in &config.abbr { + if rule.key.contains('{') { + continue; + } + let Some(expand) = rule.expand.for_shell(Shell::Bash) else { + continue; + }; + lines.push(format!( + " [{}]={}", + bash_double_quote_for_assoc(&rule.key), + bash_double_quote_for_assoc(expand), + )); + } + lines.join("\n") +} + +/// Build the `__runex_abbr_cond` associative-array body: one +/// ` ["key"]="cmd1:cmd2"` line per rule that has a non-empty +/// `when_command_exists` list for bash. +/// +/// `:` is used as the join character because the dispatcher in +/// `bash.sh` splits the list with `IFS=':'` — neither a command name +/// nor a `bash_double_quote_for_assoc`'d byte sequence can contain a +/// raw `:` that would confuse the split. Empty lists are skipped (a +/// guard of "no commands required" is equivalent to no guard at all). +fn cond_table_lines(config: &Config) -> String { + let mut lines = Vec::new(); + for rule in &config.abbr { + if rule.key.contains('{') { + continue; + } + let Some(cmds) = rule + .when_command_exists + .as_ref() + .and_then(|w| w.for_shell(Shell::Bash)) + else { + continue; + }; + if cmds.is_empty() { + continue; + } + let joined = cmds.join(":"); + lines.push(format!( + " [{}]={}", + bash_double_quote_for_assoc(&rule.key), + bash_double_quote_for_assoc(&joined), + )); + } + lines.join("\n") +} + +/// Build the `__runex_abbr_patterns` indexed-array body for rules whose +/// key contains `{number}`. +/// +/// Each emitted line is a single bash double-quoted string concatenated +/// with `$'\037'` (ANSI-C-quoted US, 0x1F) field separators: +/// +/// ```text +/// "prefix"$'\037'"suffix"$'\037'"template"$'\037'"unit" +/// ``` +/// +/// The bake dispatcher in `bash.sh` splits this with +/// `IFS=$'\037' read -r prefix suffix template unit`. US is safe as a +/// separator because the config validator rejects every ASCII control +/// character in user-facing fields, so it can never appear inside +/// `prefix` / `suffix` / `template` / `unit`. +fn pattern_table_lines(config: &Config) -> String { + let mut lines = Vec::new(); + for rule in &config.abbr { + let Some(unit) = rule.number.as_deref() else { + continue; + }; + let Some(pos) = rule.key.find(NUMBER_PLACEHOLDER) else { + continue; + }; + let Some(template) = rule.expand.for_shell(Shell::Bash) else { + continue; + }; + let prefix = &rule.key[..pos]; + let suffix = &rule.key[pos + NUMBER_PLACEHOLDER.len()..]; + let sep = "$'\\037'"; + lines.push(format!( + " {prefix}{sep}{suffix}{sep}{template}{sep}{unit}", + prefix = bash_double_quote_for_assoc(prefix), + suffix = bash_double_quote_for_assoc(suffix), + template = bash_double_quote_for_assoc(template), + unit = bash_double_quote_for_assoc(unit), + sep = sep, + )); + } + lines.join("\n") +} + +/// Generate the full cygwin/msys bake-mode dispatcher block: +/// +/// 1. `__runex_cyg_expand` — public entry, called from `__runex_expand` +/// when sourced under Git Bash (selected by the `case "${OSTYPE-}"` +/// switch at the bottom of this block). +/// 2. `__runex_abbr_expand` / `__runex_abbr_cond` / `__runex_abbr_patterns` +/// — static tables baked from `config`. +/// 3. `__runex_cyg_lookup` / `__runex_cyg_pattern_lookup` / `__runex_cyg_render` +/// — helpers that operate purely on bash variables (no subprocesses). +/// 4. `case "${OSTYPE-}"` — re-defines `__runex_expand` to either the +/// bake path (cygwin / msys) or keep the exec path (Linux / WSL). +/// +/// This block is inserted into `bash.sh` at `{BASH_CYG_DISPATCHER}` and +/// is empty when `runex export bash` is called without a config so the +/// legacy escape hatch (`eval "$(runex export bash)"`) stays unchanged. +pub(crate) fn generate_cygwin_dispatcher(config: &Config) -> String { + let exact = exact_table_lines(config); + let cond = cond_table_lines(config); + let patterns = pattern_table_lines(config); + let exact_block = if exact.is_empty() { String::new() } else { format!("\n{exact}\n") }; + let cond_block = if cond.is_empty() { String::new() } else { format!("\n{cond}\n") }; + let pattern_block = if patterns.is_empty() { String::new() } else { format!("\n{patterns}\n") }; + format!( + r#"declare -gA __runex_abbr_expand=({exact_block}) +declare -gA __runex_abbr_cond=({cond_block}) +__runex_abbr_patterns=({pattern_block}) +__runex_cyg_render() {{ + local text="$1" pos + pos="${{text%%\{{\}}*}}" + if [ "$pos" = "$text" ]; then + __runex_out="$text" + __runex_cursor_off="" + else + __runex_cursor_off="${{#pos}}" + __runex_out="${{pos}}${{text#*\{{\}}}}" + fi +}} +__runex_cyg_lookup() {{ + local key="$1" raw conds c + __runex_out="" + __runex_cursor_off="" + raw="${{__runex_abbr_expand[$key]-}}" + [ -z "$raw" ] && return + conds="${{__runex_abbr_cond[$key]-}}" + if [ -n "$conds" ]; then + local IFS=':' + for c in $conds; do command -v "$c" >/dev/null 2>&1 || return; done + fi + [ "$raw" = "$key" ] && return + __runex_cyg_render "$raw" +}} +__runex_cyg_pattern_lookup() {{ + local token="$1" entry prefix suffix template unit rest n i repeated rendered + __runex_out="" + __runex_cursor_off="" + for entry in "${{__runex_abbr_patterns[@]}}"; do + IFS=$'\037' read -r prefix suffix template unit <<<"$entry" + [ "${{token#"$prefix"}}" = "$token" ] && continue + rest="${{token#"$prefix"}}" + if [ -n "$suffix" ]; then + [ "${{rest%"$suffix"}}" = "$rest" ] && continue + rest="${{rest%"$suffix"}}" + fi + [ -z "$rest" ] && continue + case "$rest" in (*[!0-9]*) continue ;; esac + n="$rest" + [ "$n" -le 0 ] 2>/dev/null && continue + [ "$n" -gt 128 ] 2>/dev/null && continue + repeated="" + for ((i=0; i) -> .replace("{BASH_BIN}", &bash_quote_string(bin)) .replace("{BASH_BIND_LINES}", &bash_bind_lines(trigger)) .replace("{BASH_SELF_INSERT_LINES}", &bash_self_insert_lines(self_insert)) + .replace( + "{BASH_CYG_DISPATCHER}", + &config + .map(crate::app::bash_static_dispatcher::generate_cygwin_dispatcher) + .unwrap_or_default(), + ) .replace("{ZSH_BIN}", &bash_quote_string(bin)) .replace("{ZSH_BIND_LINES}", &zsh_bind_lines(trigger)) .replace("{ZSH_SELF_INSERT_LINES}", &zsh_self_insert_lines(self_insert)) @@ -612,13 +618,123 @@ mod tests { ); } + // ── Cygwin/MSYS (Git Bash) bake-mode dispatcher (issue #7) ───────── + #[test] - fn bash_script_does_not_embed_known_tokens() { - // New design: the abbreviation list is consulted at keypress time by - // `runex hook`, not baked into the bootstrap as a `case` block. This - // keeps the emitted script independent of user-supplied key strings — - // which, besides being simpler, avoids a whole class of injection - // concerns (quoting gcm's key into a `case` arm). + fn bash_script_includes_cyg_dispatcher_when_config_has_abbrs() { + let config = Config { + version: 1, + keybind: crate::domain::model::KeybindConfig { + trigger: crate::domain::model::PerShellKey { + default: Some(TriggerKey::Space), + ..Default::default() + }, + ..Default::default() + }, + precache: crate::domain::model::PrecacheConfig::default(), + abbr: vec![crate::domain::model::Abbr { + key: "gst".into(), + expand: crate::domain::model::PerShellString::All("git status".into()), + when_command_exists: None, + number: None, + }], + }; + let s = export_script(Shell::Bash, "runex", Some(&config)); + assert!( + s.contains("__runex_cyg_expand"), + "bash script must define the cygwin bake dispatcher: {s}" + ); + assert!( + s.contains("[\"gst\"]=\"git status\""), + "bash script must bake the abbreviation table for the cygwin path: {s}" + ); + } + + #[test] + fn bash_script_omits_cyg_dispatcher_when_config_is_none() { + // `runex export bash` (no config) is the legacy escape hatch. + // It must stay byte-compatible with pre-0.1.17 callers so users + // who source it via `eval "$(runex export bash)"` aren't broken. + let s = export_script(Shell::Bash, "runex", None); + // The dispatcher block is empty — no bake-mode function definition + // and no abbreviation table. The template still contains a + // `case "${OSTYPE-}"` switch and a defensive `declare -F` probe, + // but with no `__runex_cyg_expand` defined the probe falls through + // to the exec path. That's the legacy escape hatch contract. + assert!( + !s.contains("__runex_cyg_expand() {"), + "bash export without a config must not define the bake dispatcher function: {s}" + ); + assert!( + !s.contains("__runex_abbr_expand"), + "bash export without a config must not bake the abbreviation table: {s}" + ); + } + + #[test] + fn bash_script_includes_ostype_dispatcher_switch() { + let config = Config { + version: 1, + keybind: crate::domain::model::KeybindConfig { + trigger: crate::domain::model::PerShellKey { + default: Some(TriggerKey::Space), + ..Default::default() + }, + ..Default::default() + }, + precache: crate::domain::model::PrecacheConfig::default(), + abbr: vec![], + }; + let s = export_script(Shell::Bash, "runex", Some(&config)); + assert!( + s.contains("case \"${OSTYPE-}\""), + "bash script must dispatch on OSTYPE at source time: {s}" + ); + assert!( + s.contains("msys*|cygwin*"), + "bash script must recognize msys/cygwin OSTYPE values: {s}" + ); + } + + #[test] + fn bash_script_keeps_legacy_exec_path_function_for_non_cygwin() { + let config = Config { + version: 1, + keybind: crate::domain::model::KeybindConfig { + trigger: crate::domain::model::PerShellKey { + default: Some(TriggerKey::Space), + ..Default::default() + }, + ..Default::default() + }, + precache: crate::domain::model::PrecacheConfig::default(), + abbr: vec![], + }; + let s = export_script(Shell::Bash, "runex", Some(&config)); + assert!( + s.contains("__runex_exec_expand"), + "bash script must keep the legacy `runex hook` exec path: {s}" + ); + assert!( + s.contains("hook --shell bash"), + "the exec path still invokes `runex hook`: {s}" + ); + } + + #[test] + fn legacy_exec_path_does_not_embed_known_tokens() { + // The Linux/WSL exec path consults the abbreviation list at keypress + // time by spawning `runex hook`, not by baking tokens into the + // bootstrap — that's what keeps the exec path independent of user + // key strings and avoids a whole class of injection concerns + // (quoting gcm's key into a `case` arm). + // + // The cygwin/msys bake path *does* embed tokens in + // `__runex_abbr_expand[...]` by design (issue #7 workaround), so we + // only assert here that the legacy bash single-quote form (`'gcm'`) + // and the historical helper name are absent. The bake path uses + // double quotes (`"gcm"`) inside an associative array, which is + // syntactically distinct. let config = Config { version: 1, keybind: crate::domain::model::KeybindConfig::default(), @@ -631,8 +747,14 @@ mod tests { }], }; let s = export_script(Shell::Bash, "runex", Some(&config)); - assert!(!s.contains("'gcm'"), "bash bootstrap must not embed tokens anymore"); - assert!(!s.contains("__runex_is_known_token"), "legacy helper removed"); + assert!( + !s.contains("'gcm'"), + "legacy exec path must not single-quote-embed tokens: {s}" + ); + assert!( + !s.contains("__runex_is_known_token"), + "legacy helper removed (was for the pre-hook bake path)" + ); } #[test] diff --git a/runex/src/domain/templates/bash.sh b/runex/src/domain/templates/bash.sh index 7891351..da93229 100644 --- a/runex/src/domain/templates/bash.sh +++ b/runex/src/domain/templates/bash.sh @@ -4,7 +4,15 @@ case $- in *i*) ;; *) return 0 ;; esac -__runex_expand() { +# === Cygwin/MSYS (Git Bash) bake-mode dispatcher (issue #7) ================ +# In Git Bash spawning a Win32 .exe from inside the trigger handler causes +# the next SIGINT to be lost. To avoid that, the cygwin path looks the +# abbreviation up in a static table baked into this cache file. The block +# below is generated by app::bash_static_dispatcher and is empty when +# runex export bash is invoked without a config (legacy escape hatch). +{BASH_CYG_DISPATCHER} +# === Linux / WSL / generic bash exec dispatcher (legacy path) ============== +__runex_exec_expand() { local out if out=$({BASH_BIN} hook --shell bash --line "$READLINE_LINE" --cursor "$READLINE_POINT" 2>/dev/null) && [ -n "$out" ]; then eval "$out" @@ -13,6 +21,25 @@ __runex_expand() { READLINE_POINT=$((READLINE_POINT + 1)) fi } +# === Public entry point ==================================================== +# The trigger handler invokes __runex_expand. Selection is done once at +# source time because OSTYPE is immutable per session: under msys/cygwin +# (Git Bash) we route to the bake path if it was emitted (config-driven), +# otherwise we keep the exec path. The bake path is empty when +# runex export bash was called without a config (legacy escape hatch), +# so we fall back to exec by checking whether the bake function exists. +case "${OSTYPE-}" in + msys*|cygwin*) + if declare -F __runex_cyg_expand >/dev/null 2>&1; then + __runex_expand() { __runex_cyg_expand; } + else + __runex_expand() { __runex_exec_expand; } + fi + ;; + *) + __runex_expand() { __runex_exec_expand; } + ;; +esac __runex_self_insert() { READLINE_LINE="${READLINE_LINE:0:READLINE_POINT} ${READLINE_LINE:READLINE_POINT}" READLINE_POINT=$((READLINE_POINT + 1)) diff --git a/runex/src/infra/integration_cache.rs b/runex/src/infra/integration_cache.rs index 3750628..5a5f896 100644 --- a/runex/src/infra/integration_cache.rs +++ b/runex/src/infra/integration_cache.rs @@ -48,7 +48,17 @@ use crate::infra::env::{xdg_cache_home_with, HomeDirResolver}; /// header layout changes in a way that doctor / future runex /// versions need to reject. Read by /// [`crate::infra::integration_check::check_cache_freshness`]. -pub(crate) const INTEGRATION_CACHE_VERSION: u32 = 1; +/// +/// History +/// ------- +/// - `1` (0.1.13–0.1.16): runtime-hook integration; `bind -x` invokes +/// `runex hook` on every keypress. +/// - `2` (0.1.17): bash integration adds the bake-mode dispatcher used +/// by the cygwin/msys path (issue #7 workaround). v1 caches still +/// load on the exec path, so the bump exists to push `runex doctor` +/// into nudging Git Bash users back to `runex init bash` so they +/// pick up the Ctrl+C fix. +pub(crate) const INTEGRATION_CACHE_VERSION: u32 = 2; /// Marker token that appears in the cache header so doctor can /// re-identify a runex-managed file even if the user has renamed @@ -291,7 +301,8 @@ mod tests { #[test] fn cache_header_contains_required_fields() { let h = cache_header("#", "/abs/path/to/runex"); - assert!(h.contains("runex-integration-version: 1")); + let expected_version = format!("runex-integration-version: {INTEGRATION_CACHE_VERSION}"); + assert!(h.contains(&expected_version), "header missing version field: {h}"); assert!(h.contains("runex-bin: /abs/path/to/runex")); assert!(h.contains("do not edit")); } diff --git a/runex/tests/bash_cygwin_bake_pty.rs b/runex/tests/bash_cygwin_bake_pty.rs new file mode 100644 index 0000000..398cb16 --- /dev/null +++ b/runex/tests/bash_cygwin_bake_pty.rs @@ -0,0 +1,283 @@ +//! End-to-end smoke test for the cygwin/msys bake-mode bash dispatcher +//! (issue #7 workaround). +//! +//! The real bug only reproduces under Git Bash on Windows because it +//! depends on cygwin's bind-x + Win32 spawn signal interaction. We +//! can't reproduce *that* on Linux CI, but we *can* prove the bake +//! path is wired up correctly: if a Linux bash session is told it is +//! cygwin (`OSTYPE=msys`) at source time, our `case "${OSTYPE-}"` +//! switch should route the trigger to `__runex_cyg_expand`, which +//! does its lookup purely in bash and never calls `runex hook`. The +//! cache file is identical to the one Git Bash users get; only the +//! dispatcher selection differs. +//! +//! This file covers: +//! +//! 1. simple map expansion (`gst` → `git status`) +//! 2. `{number}` pattern expansion (`up3` → `cd ../../../`) +//! 3. cursor placeholder (`gca` → `git commit -am ''`) +//! +//! Linux only — bash 4+ required for `bind -x` and `declare -gA`. + +#![cfg(target_family = "unix")] + +use std::process::Command; +use std::time::Duration; + +use tempfile::tempdir; + +fn bin_path() -> &'static str { + env!("CARGO_BIN_EXE_runex") +} + +fn bash4_available() -> bool { + let Ok(path) = which::which("bash") else { return false }; + let out = Command::new(path) + .args(["--norc", "--noprofile", "-c", "echo $BASH_VERSION"]) + .output(); + let Ok(out) = out else { return false }; + let ver = String::from_utf8_lossy(&out.stdout); + ver.trim() + .split('.') + .next() + .and_then(|s| s.parse::().ok()) + .is_some_and(|major| major >= 4) +} + +/// Run `runex init bash --yes` against an isolated HOME with a config +/// that exercises all three expansion shapes the bake path supports. +fn user_runs_init_bash_with_full_config(home: &std::path::Path) -> std::path::PathBuf { + let bin = bin_path(); + let cfg_dir = home.join(".config").join("runex"); + std::fs::create_dir_all(&cfg_dir).unwrap(); + let cfg = cfg_dir.join("config.toml"); + std::fs::write( + &cfg, + r#"version = 1 + +[keybind.trigger] +default = "space" + +[[abbr]] +key = "gst" +expand = "echo EXPANDED_GST" + +[[abbr]] +key = "gca" +expand = "echo PRE_{}_POST" + +[[abbr]] +key = "up{number}" +expand = "echo UP_{number}_END" +number = "x" +"#, + ) + .unwrap(); + let out = Command::new(bin) + .env("HOME", home) + .env("USERPROFILE", home) + .env("XDG_CACHE_HOME", home.join(".cache")) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("SHELL", "/bin/bash") + .args(["--config", cfg.to_str().unwrap(), "init", "bash", "--yes"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "`runex init bash --yes` must succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + home.join(".bashrc") +} + +/// Spawn an interactive bash that pretends to be Git Bash via +/// `OSTYPE=msys` before sourcing the cache. After this `__runex_expand` +/// is bound to `__runex_cyg_expand` so all subsequent trigger presses +/// hit the bake path without spawning `runex hook`. +fn spawn_cyg_bash(rcfile: &std::path::Path, home: &std::path::Path) -> expectrl::Session { + let bash = which::which("bash").expect("bash on PATH"); + let mut session = expectrl::spawn(format!( + "{bash} --norc -i", + bash = bash.display() + )) + .expect("spawn interactive bash"); + session.set_expect_timeout(Some(Duration::from_secs(5))); + + session + .send_line(&format!("export HOME={}", home.display())) + .ok(); + session + .send_line(&format!( + "export XDG_CACHE_HOME={} XDG_CONFIG_HOME={}", + home.join(".cache").display(), + home.join(".config").display() + )) + .ok(); + session.send_line("export OSTYPE=msys").ok(); + session + .send_line("bind 'set enable-bracketed-paste off' 2>/dev/null") + .ok(); + session.send_line("PS1='__PTY__ '").ok(); + // Source the rcfile *after* OSTYPE is set so the case "${OSTYPE-}" + // switch routes us to the bake path at source time. The rcfile + // sources the cache file in turn. + session.send_line(&format!("source {}", rcfile.display())).ok(); + + use expectrl::Regex; + session.expect(Regex(r"__PTY__\s*$")).ok(); + session +} + +#[test] +fn cygwin_bake_expands_plain_abbreviation() { + if !bash4_available() { + eprintln!("skipping: bash 4+ not available"); + return; + } + let dir = tempdir().unwrap(); + let home = dir.path(); + let rcfile = user_runs_init_bash_with_full_config(home); + let mut session = spawn_cyg_bash(&rcfile, home); + + session.send("gst ").ok(); + session.send_line("").ok(); + + use expectrl::Regex; + let saw = session.expect(Regex(r"EXPANDED_GST")).is_ok(); + session.send_line("exit").ok(); + assert!( + saw, + "the cygwin bake path must expand `gst` to `echo EXPANDED_GST` \ + without invoking `runex hook`; the PTY never saw EXPANDED_GST" + ); +} + +#[test] +fn cygwin_bake_expands_pattern_with_number_placeholder() { + if !bash4_available() { + eprintln!("skipping: bash 4+ not available"); + return; + } + let dir = tempdir().unwrap(); + let home = dir.path(); + let rcfile = user_runs_init_bash_with_full_config(home); + let mut session = spawn_cyg_bash(&rcfile, home); + + // `up3` → token matches the `up{number}` pattern with n=3 and + // unit="x", so the rendered expansion is `echo UP_xxx_END`. + session.send("up3 ").ok(); + session.send_line("").ok(); + + use expectrl::Regex; + let saw = session.expect(Regex(r"UP_xxx_END")).is_ok(); + session.send_line("exit").ok(); + assert!( + saw, + "the cygwin bake path must expand `up3` via the pattern \ + table to `echo UP_xxx_END`; the PTY never saw UP_xxx_END" + ); +} + +#[test] +fn cygwin_bake_strips_cursor_placeholder_from_rendered_expansion() { + if !bash4_available() { + eprintln!("skipping: bash 4+ not available"); + return; + } + let dir = tempdir().unwrap(); + let home = dir.path(); + let rcfile = user_runs_init_bash_with_full_config(home); + let mut session = spawn_cyg_bash(&rcfile, home); + + // `gca` expand = `echo PRE_{}_POST`. The `{}` placeholder is the + // cursor marker — it must NOT appear literally in the rendered + // command. After expansion the line should read `echo PRE__POST` + // with the cursor positioned between the two underscores. + session.send("gca ").ok(); + session.send_line("").ok(); + + use expectrl::Regex; + let saw_rendered = session.expect(Regex(r"PRE__POST")).is_ok(); + session.send_line("exit").ok(); + assert!( + saw_rendered, + "the cygwin bake path must drop the `{{}}` cursor placeholder \ + when rendering the expansion; expected `PRE__POST` in PTY stdout" + ); +} + +#[test] +fn cygwin_bake_expands_even_when_token_is_not_in_command_position() { + // *** 0.1.17 interim degradation vs. the exec path *** + // + // The Rust hook (`domain::hook::is_command_position`) walks the + // line and refuses to expand `gst` when it appears after `echo`, + // inside a pipeline (after `|`, but note that `|`/`||`/`&&`/`;` + // and `sudo` are themselves *command-position* prefixes — see + // docs/recipes.md). The 0.1.17 bake path skips the check + // entirely, so the cygwin path expands any trailing token that + // matches an abbreviation regardless of the preceding word. + // Re-implementing the state machine in pure bash is feasible + // and is tracked for 0.1.18; carving it out kept the Ctrl+C fix + // small enough to ship as a focused release. + // + // This test pins the 0.1.17 behaviour so the 0.1.18 fix is an + // intentional behaviour change (= flip the assertion / delete + // this test) rather than a stealth regression. Documented in + // docs/setup.{md,ja.md} and CHANGELOG.md. + if !bash4_available() { + eprintln!("skipping: bash 4+ not available"); + return; + } + let dir = tempdir().unwrap(); + let home = dir.path(); + let rcfile = user_runs_init_bash_with_full_config(home); + let mut session = spawn_cyg_bash(&rcfile, home); + + // Type `echo gst` and then the trigger Space. On the exec path this + // would NOT expand (gst is in argument position, not command + // position). On the cygwin bake path it DOES expand, producing + // `echo echo EXPANDED_GST` which echoes the literal string + // `echo EXPANDED_GST` to stdout. + session.send("echo gst ").ok(); + session.send_line("").ok(); + + use expectrl::Regex; + let saw = session.expect(Regex(r"echo EXPANDED_GST")).is_ok(); + session.send_line("exit").ok(); + assert!( + saw, + "cygwin bake path is documented to skip command-position checking: \ + `echo gst` must expand `gst` even in argument position. \ + If you got here because you implemented command-position detection \ + in the bake dispatcher, update docs/setup.{{md,ja.md}} and this test." + ); +} + +#[test] +fn cygwin_bake_falls_through_when_token_is_not_an_abbreviation() { + if !bash4_available() { + eprintln!("skipping: bash 4+ not available"); + return; + } + let dir = tempdir().unwrap(); + let home = dir.path(); + let rcfile = user_runs_init_bash_with_full_config(home); + let mut session = spawn_cyg_bash(&rcfile, home); + + // A token that isn't in either table must be left alone (a single + // space is appended, just like the legacy self-insert). `echo` + // then runs literally with the token as its argument. + session.send("echo NOTANABBR ").ok(); + session.send_line("").ok(); + + use expectrl::Regex; + let saw = session.expect(Regex(r"NOTANABBR")).is_ok(); + session.send_line("exit").ok(); + assert!( + saw, + "unknown tokens must self-insert a Space and execute as typed; \ + the PTY never saw NOTANABBR on stdout" + ); +} diff --git a/runex/tests/bash_gitbash_smoke.rs b/runex/tests/bash_gitbash_smoke.rs new file mode 100644 index 0000000..485532f --- /dev/null +++ b/runex/tests/bash_gitbash_smoke.rs @@ -0,0 +1,437 @@ +//! Windows-local smoke test for the Git Bash bake-mode dispatcher +//! (issue #7 workaround). Runs `bash -c` against the cache file in +//! a non-interactive shell so we don't need a Windows PTY backend +//! (expectrl is not safely available on Windows at the moment). +//! +//! What this covers: +//! +//! 1. `runex export bash` generates a cache file whose bash syntax +//! is valid under the real Git Bash binary (`bash -n`). +//! 2. With OSTYPE in `(msys, cygwin, msys2)`, sourcing the cache +//! routes `__runex_expand` to the bake dispatcher +//! (`__runex_cyg_expand`). +//! 3. Calling `__runex_expand` with `READLINE_LINE=gst` / +//! `READLINE_POINT=3` rewrites the line to `git status` in pure +//! bash — no subprocess spawn — which is exactly the property +//! that fixes the Ctrl+C signal loss on real Git Bash. +//! 4. The `{number}` pattern table renders correctly. +//! 5. The `{}` cursor placeholder is stripped from the rendered +//! line and the cursor offset is reported back via +//! `READLINE_POINT`. +//! 6. Non-msys/cygwin OSTYPE values fall through to the exec path. +//! +//! What this does NOT cover (= same as `bash_cygwin_bake_pty.rs`): +//! +//! - The cygwin signal interference that actually motivates the +//! fix. `bash -c` runs non-interactively and doesn't load +//! readline, so we can't reproduce the `bind -x` + SIGINT +//! interaction here. Verifying the fix end-to-end remains a +//! manual step in the release checklist. +//! +//! Windows only. Skips silently if Git Bash isn't installed at the +//! default Git for Windows path. + +#![cfg(windows)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::tempdir; + +fn runex_bin() -> &'static str { + env!("CARGO_BIN_EXE_runex") +} + +/// Resolve the Git Bash binary. We deliberately avoid `where bash` +/// because the WSL launcher (`C:\Windows\System32\bash.exe`) usually +/// resolves first and is not the cygwin bash we want to test. +fn git_bash() -> Option { + let candidates = [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ]; + candidates.iter().map(PathBuf::from).find(|p| p.exists()) +} + +/// Resolve the MSYS2 bash binary. MSYS2 is a separate install from +/// Git for Windows; tests skip silently when it isn't found. MSYS2's +/// `usr/bin/bash` reports `OSTYPE=cygwin` (not `msys`), so a passing +/// MSYS2 run also proves the `cygwin*` arm of the dispatcher case. +fn msys2_bash() -> Option { + let candidates = [ + r"C:\msys64\usr\bin\bash.exe", + r"C:\msys2\usr\bin\bash.exe", + r"C:\tools\msys64\usr\bin\bash.exe", + ]; + let env_paths = [ + std::env::var("MSYS2_PATH_TYPE").ok(), + std::env::var("MSYS").ok(), + ]; + candidates + .iter() + .map(PathBuf::from) + .chain(env_paths.iter().flatten().map(|p| { + PathBuf::from(p) + .join("usr") + .join("bin") + .join("bash.exe") + })) + .find(|p| p.exists()) +} + +/// Resolve the upstream Cygwin (cygwin.com) bash binary. Different +/// project from MSYS2 — closer to the original cygwin newlib + DLL, +/// often installed at `C:\cygwin64`. Like MSYS2 it sets +/// `OSTYPE=cygwin`, but the underlying cygwin1.dll is a separate +/// codebase, so a passing run here proves the dispatcher works on +/// the real cygwin runtime (not just msys2's fork). Skipped when +/// not installed, which is the common case on CI. +fn cygwin_bash() -> Option { + let candidates = [ + r"C:\cygwin64\bin\bash.exe", + r"C:\cygwin\bin\bash.exe", + r"C:\tools\cygwin\bin\bash.exe", + ]; + candidates.iter().map(PathBuf::from).find(|p| p.exists()) +} + +/// Generic resolver used by the parameterised tests below. Returns +/// (label, path) pairs for whichever cygwin-family bash binaries the +/// machine has installed. An empty result means "skip all dispatcher +/// tests" — never a failure on its own, since the suite still +/// catches cache-generation regressions through the +/// `generated_cache_passes_*_syntax_check` tests that run per binary. +fn cygwin_family_bashes() -> Vec<(&'static str, PathBuf)> { + let mut out = Vec::new(); + if let Some(p) = git_bash() { + out.push(("Git Bash", p)); + } + if let Some(p) = msys2_bash() { + out.push(("MSYS2 bash", p)); + } + if let Some(p) = cygwin_bash() { + out.push(("Cygwin bash", p)); + } + out +} + +/// Write a config that exercises every shape the bake path supports +/// and generate the cache file through `runex export bash --bin <...>`. +/// Returns `(cache_path, runex_bin_path)`. +fn build_cache(home: &Path) -> (PathBuf, String) { + let cfg_dir = home.join(".config").join("runex"); + std::fs::create_dir_all(&cfg_dir).unwrap(); + let cfg = cfg_dir.join("config.toml"); + std::fs::write( + &cfg, + r#"version = 1 + +[keybind.trigger] +default = "space" + +[[abbr]] +key = "gst" +expand = "git status" + +[[abbr]] +key = "gca" +expand = "git commit -am '{}'" + +[[abbr]] +key = "up{number}" +expand = "cd {number}" +number = "../" +"#, + ) + .unwrap(); + + let bin = runex_bin().to_string(); + let cache_path = home + .join(".cache") + .join("runex") + .join("integration.bash"); + std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap(); + + let out = Command::new(&bin) + .args([ + "--config", + cfg.to_str().unwrap(), + "export", + "bash", + "--bin", + &bin, + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "`runex export bash` must succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + std::fs::write(&cache_path, &out.stdout).unwrap(); + (cache_path, bin) +} + +/// Convert a Windows path to the POSIX form Git Bash expects in +/// double-quoted strings, e.g. `C:\foo\bar` → `/c/foo/bar`. Git +/// Bash's `bash` accepts both, but POSIX form keeps backslash-vs- +/// escape ambiguity out of the test fixtures. +fn to_posix_path(p: &Path) -> String { + let s = p.to_string_lossy().replace('\\', "/"); + if s.len() >= 2 && s.as_bytes()[1] == b':' { + let drive = s.as_bytes()[0].to_ascii_lowercase() as char; + format!("/{}{}", drive, &s[2..]) + } else { + s + } +} + +/// Strip the cache file's non-interactive early-return guard so we +/// can source it from `bash -c`. The guard +/// (`case $- in *i*) ;; *) return 0 ;; esac`) is intentionally +/// emitted by the cache template — it prevents cron / CI scripts +/// from accidentally loading abbreviation tables. For this smoke +/// test, though, we want the bake dispatcher to install itself even +/// under non-interactive bash, so we copy the cache into the temp +/// dir with that single guard block elided. The rest of the file +/// (and crucially, the dispatcher selection `case "${OSTYPE-}"`) is +/// preserved bit-for-bit. +fn cache_without_interactive_guard(src: &Path, dst: &Path) { + let body = std::fs::read_to_string(src).unwrap(); + let mut out_lines: Vec<&str> = Vec::with_capacity(body.lines().count()); + let mut skipping = 0u8; + for line in body.lines() { + let trimmed = line.trim_start(); + if skipping == 0 && trimmed.starts_with("case $- in") { + // Skip the next two lines (` *i*) ;;` and + // ` *) return 0 ;;`) and the closing `esac`. + skipping = 4; + } + if skipping > 0 { + skipping -= 1; + continue; + } + out_lines.push(line); + } + std::fs::write(dst, out_lines.join("\n") + "\n").unwrap(); +} + +/// Run a bash script under Git Bash with a given OSTYPE, sourcing +/// the cache file first. Returns stdout (panics on non-zero exit). +fn run_under_gitbash(bash: &Path, cache: &Path, ostype: &str, script: &str) -> String { + // Strip the interactive guard into a sibling file so we can + // source it from `bash -c`. The original cache is untouched — + // every other test in this module reads it as the user would. + let dst = cache.with_extension("bash.test"); + cache_without_interactive_guard(cache, &dst); + + let wrapper = format!( + "export OSTYPE={ostype}\nsource '{cache}'\n{script}", + ostype = ostype, + cache = to_posix_path(&dst), + script = script, + ); + let out = Command::new(bash) + .args(["--norc", "--noprofile", "-c", &wrapper]) + .output() + .unwrap_or_else(|e| panic!("failed to invoke bash at {}: {e}", bash.display())); + assert!( + out.status.success(), + "bash script must succeed at {} (OSTYPE={ostype})\nscript:\n{script}\nstdout:\n{}\nstderr:\n{}", + bash.display(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + String::from_utf8(out.stdout).expect("bash stdout must be UTF-8") +} + +/// Wrap a `run_under_gitbash` call with a per-binary label so +/// failure messages identify which cygwin-family bash blew up. +fn run_with_label(label: &str, bash: &Path, cache: &Path, ostype: &str, script: &str) -> String { + let out = run_under_gitbash(bash, cache, ostype, script); + eprintln!("[{label}] OSTYPE={ostype} stdout:\n{out}"); + out +} + +/// Skip-aware iteration: if no cygwin-family bash is on the host, +/// the test prints a skip notice and returns. Otherwise the closure +/// runs once per available binary with its label / path. +fn for_each_cygwin_bash(test_name: &str, body: impl Fn(&str, &Path)) { + let bashes = cygwin_family_bashes(); + if bashes.is_empty() { + eprintln!("{test_name}: skipping (no Git Bash or MSYS2 bash installed)"); + return; + } + for (label, bash) in bashes { + body(label, &bash); + } +} + +#[test] +fn generated_cache_passes_syntax_check_on_every_cygwin_bash() { + for_each_cygwin_bash("generated_cache_passes_syntax_check", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + let out = Command::new(bash) + .args(["-n", &to_posix_path(&cache)]) + .output() + .unwrap(); + assert!( + out.status.success(), + "[{label}] `bash -n` must accept the generated cache file\n\ + stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + }); +} + +#[test] +fn routes_to_bake_dispatcher_under_cygwin_family_ostypes() { + for_each_cygwin_bash("routes_to_bake_dispatcher", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + // MSYS2's real OSTYPE is `cygwin`, Git Bash's is `msys`. + // We test both literal values plus `msys2` for completeness; + // any cygwin-family OSTYPE must select the bake path. + for ostype in ["msys", "cygwin", "msys2"] { + let out = run_with_label( + label, + bash, + &cache, + ostype, + "declare -f __runex_expand | grep -q __runex_cyg_expand && echo CYG || echo OTHER", + ); + assert_eq!( + out.trim(), + "CYG", + "[{label}] OSTYPE={ostype} must route to bake dispatcher" + ); + } + }); +} + +#[test] +fn routes_to_exec_dispatcher_under_non_cygwin_ostype() { + for_each_cygwin_bash("routes_to_exec_dispatcher", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + let out = run_with_label( + label, + bash, + &cache, + "linux-gnu", + "declare -f __runex_expand | grep -q __runex_exec_expand && echo EXEC || echo OTHER", + ); + assert_eq!( + out.trim(), + "EXEC", + "[{label}] OSTYPE=linux-gnu must route to exec dispatcher" + ); + }); +} + +#[test] +fn bake_expands_simple_abbreviation_on_every_cygwin_bash() { + for_each_cygwin_bash("bake_expands_simple", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + let out = run_with_label( + label, + bash, + &cache, + "msys", + r#"READLINE_LINE="gst" +READLINE_POINT=3 +__runex_expand +echo "LINE=$READLINE_LINE" +echo "POINT=$READLINE_POINT""#, + ); + assert!( + out.contains("LINE=git status"), + "[{label}] bake path must rewrite `gst` to `git status`; got:\n{out}" + ); + assert!( + out.contains("POINT=11"), + "[{label}] bake path must place the cursor at end of `git status ` (11); got:\n{out}" + ); + }); +} + +#[test] +fn bake_expands_number_pattern_on_every_cygwin_bash() { + for_each_cygwin_bash("bake_expands_number_pattern", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + let out = run_with_label( + label, + bash, + &cache, + "msys", + r#"READLINE_LINE="up3" +READLINE_POINT=3 +__runex_expand +echo "LINE=$READLINE_LINE""#, + ); + assert!( + out.contains("LINE=cd ../../../"), + "[{label}] bake path must render `up3` via the pattern table to `cd ../../../`; got:\n{out}" + ); + }); +} + +#[test] +fn bake_strips_cursor_placeholder_on_every_cygwin_bash() { + for_each_cygwin_bash("bake_strips_cursor_placeholder", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + let out = run_with_label( + label, + bash, + &cache, + "msys", + r#"READLINE_LINE="gca" +READLINE_POINT=3 +__runex_expand +echo "LINE=$READLINE_LINE" +echo "POINT=$READLINE_POINT""#, + ); + assert!( + out.contains("LINE=git commit -am ''"), + "[{label}] bake path must drop the `{{}}` placeholder; got:\n{out}" + ); + assert!( + out.contains("POINT=16"), + "[{label}] bake path must report cursor offset 16 (between the quotes); got:\n{out}" + ); + }); +} + +#[test] +fn bake_self_inserts_unknown_token_on_every_cygwin_bash() { + for_each_cygwin_bash("bake_self_inserts_unknown_token", |label, bash| { + let dir = tempdir().unwrap(); + let (cache, _bin) = build_cache(dir.path()); + let out = run_with_label( + label, + bash, + &cache, + "msys", + r#"READLINE_LINE="zzzzz" +READLINE_POINT=5 +__runex_expand +echo "LINE=$READLINE_LINE" +echo "POINT=$READLINE_POINT""#, + ); + assert!( + out.contains("LINE=zzzzz "), + "[{label}] unknown token must self-insert a space; got:\n{out}" + ); + assert!( + out.contains("POINT=6"), + "[{label}] unknown-token self-insert must advance cursor by 1; got:\n{out}" + ); + }); +} diff --git a/runex/tests/shell_integration.rs b/runex/tests/shell_integration.rs index 588ce22..c280dae 100644 --- a/runex/tests/shell_integration.rs +++ b/runex/tests/shell_integration.rs @@ -161,7 +161,7 @@ fn cache_header_pins_version_and_bin() { let body = std::fs::read_to_string(&cache).unwrap(); let head: Vec<&str> = body.lines().take(3).collect(); assert!( - head.iter().any(|l| l.contains("runex-integration-version: 1")), + head.iter().any(|l| l.contains("runex-integration-version:")), "cache must contain version header: head=\n{head:#?}" ); assert!(