Compare commits

...

38 commits

Author SHA1 Message Date
Loric ANDRE dd3b09a687 release: v5.7.0
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
2026-09-08 14:52:59 +02:00
LoricAndre 672639fd80
fix: stream preview command instead of waiting for completion (#1176)
closes #1174
2026-09-08 11:57:31 +00:00
dependabot[bot] a177153700
chore(deps): bump ratatui-image in the cargo-prod group (#1175)
Bumps the cargo-prod group with 1 update: [ratatui-image](https://github.com/ratatui/ratatui-image).


Updates `ratatui-image` from 11.0.7 to 11.0.8
- [Release notes](https://github.com/ratatui/ratatui-image/releases)
- [Changelog](https://github.com/ratatui/ratatui-image/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ratatui/ratatui-image/compare/v11.0.7...v11.0.8)

---
updated-dependencies:
- dependency-name: ratatui-image
  dependency-version: 11.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 13:13:29 +02:00
Loric ANDRE 801d1ab036 docs: update benchmarks
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
2026-09-06 22:47:38 +02:00
LoricAndre 17c517fd02
chore: nitpicks after multithreading review (#1172)
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
* chore: nitpicks after multithreading review

* chore: remove call sites from ARCHITECTURE.md

* chore: minor reliability corrections
2026-09-05 20:35:27 +02:00
LoricAndre 0392eeb70d
ci: lock everything to make sure dependencies match (#1170)
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
* ci: lock everything to make sure dependencies match

* fix: build locked for msrv too
2026-09-04 11:56:33 +02:00
skim-rs-bot[bot] 085f86b127
release: v5.6.7 (#1169)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 09:31:02 +00:00
Loric ANDRE 16c6f0b386 chore: update dependencies after tinyvec breakage 2026-09-04 11:04:21 +02:00
LoricAndre d7b799b9e6
fix: make min-height work again (#1168)
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
* fix: make min-height work again

* fix: revert to String and add integration tests

* chore: misc warnings

* fix: windows tests

* fix: ci public api fails because of incompatible deps version between HEAD and release
2026-09-04 00:04:26 +02:00
dependabot[bot] a9b0fd4393
chore(deps): bump taiki-e/install-action in the gha-prod group (#1166)
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
Bumps the gha-prod group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action).


Updates `taiki-e/install-action` from 2.85.4 to 2.87.1
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Commits](https://github.com/taiki-e/install-action/compare/v2.85.4...v2.87.1)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.87.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gha-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 09:06:02 +02:00
dependabot[bot] a53ada910a
chore(deps): bump the cargo-prod group with 2 updates (#1165)
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
Bumps the cargo-prod group with 2 updates: [indexmap](https://github.com/indexmap-rs/indexmap) and [which](https://github.com/harryfei/which-rs).


Updates `indexmap` from 2.14.0 to 2.14.1
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.14.0...2.14.1)

Updates `which` from 8.0.5 to 8.0.6
- [Release notes](https://github.com/harryfei/which-rs/releases)
- [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harryfei/which-rs/compare/8.0.5...8.0.6)

---
updated-dependencies:
- dependency-name: indexmap
  dependency-version: 2.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
- dependency-name: which
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 11:04:49 +02:00
skim-rs-bot[bot] 4b962af9ed
release: v5.6.6 (#1163)
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-22 17:21:53 +02:00
VXNCXNX e8f5b3ae65
fix: path_name_offset returns bytes while Rank::begin is a char index (#1160)
The PathName tiebreak computes path_name_offset - begin, so mixing the
two units inflates the score for any path with a non-ASCII directory
component and ranks a filename match below a directory match.

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-22 13:23:57 +00:00
VXNCXNX d699733053
fix(filter): --filter hangs when the query is below --min-query-length (#1158)
Co-authored-by: VXNCXNX <vxncxnx@users.noreply.github.com>
2026-08-22 09:19:38 +00:00
LoricAndre 756b084841
ci: fix auto update branch 2026-08-21 23:07:31 +02:00
dependabot[bot] 52275d3a3e
chore(deps): bump frizbee from 0.12.0 to 0.13.0 in the cargo-prod group (#1162)
Bumps the cargo-prod group with 1 update: [frizbee](https://github.com/saghen/frizbee).


Updates `frizbee` from 0.12.0 to 0.13.0
- [Release notes](https://github.com/saghen/frizbee/releases)
- [Changelog](https://github.com/saghen/frizbee/blob/main/CHANGELOG.md)
- [Commits](https://github.com/saghen/frizbee/compare/v0.12.0...v0.13.0)

---
updated-dependencies:
- dependency-name: frizbee
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 21:04:59 +00:00
Loric ANDRE 2ec03e630c ci: automatically update PRs on pushes to master 2026-08-21 22:32:29 +02:00
Loric ANDRE c0786aa9d3 chore: clippy & fmt after cargo update 2026-08-21 22:18:54 +02:00
LoricAndre bb80ac138e
fix: do not feed items through a fifo in zsh completions (#1164)
* fix(zsh): don't relay completion items through a named pipe

`_skim_complete` handed its candidates to skim through a fifo at the fixed
path `$TMPDIR/skim-complete-fifo-$$`, created by `_skim_feed_fifo` and fed by
a backgrounded `cat`. Opening a fifo for reading blocks until a writer opens
the other end, so if the feeder ever fails to get there -- it dies, or a stale
fifo left behind by an interrupted completion makes `mkfifo` fail, or the
leftover in the world-writable temp dir belongs to someone else -- the widget
blocks forever with nothing drawn and no way to interrupt it. Interrupted
completions also leaked the fifos that set this up.

That rendezvous is the only step of the completion path that can block
indefinitely, and `kill` is the only completion that reaches it without the
`**` trigger, so `kill<TAB>` is where it surfaces.

The fifo is not needed in the first place: the `_skim_complete_*` helpers pass
their candidates on `_skim_complete`'s stdin, and the command substitution that
runs skim inherits fd 0, so skim can read them directly. (fzf relays through a
fifo because its completion functions cannot pass their own stdin along; this
file is zsh-only and has no such constraint.)

Verified in a pty, in tmux and under kitty that `kill`, `ssh`, `export`,
`unset`, `unalias` and the path/dir completions all still populate skim,
that multi-select still runs through the `_post` filters (`kill<TAB>` still
inserts bare PIDs), and that no fifos are left in the temp dir.

Refs: https://github.com/skim-rs/skim/issues/1161

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFgaE1MjSaUGAGtR4aqVzT

* cleanup comms

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 22:14:19 +02:00
VXNCXNX 9de4825e51
fix(matcher): an inverse query only checks the first --nth field (#1159)
Co-authored-by: VXNCXNX <vxncxnx@users.noreply.github.com>
2026-08-21 22:06:58 +02:00
skim-rs-bot[bot] 1b80cff3f7
release: v5.6.5 (#1157)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-16 09:35:40 +00:00
VXNCXNX 94022921a3
fix(field): an out-of-range field index silently matches field 1 (#1155)
* fix(field): saturate out-of-range field indices to i32 bounds

Replace unwrap_or(1)/unwrap_or(-1) with saturation to i32 bounds so out-of-range indices don't silently become field 1. Affects --nth, --with-nth, --hide-nth and {N} in --output-format.

* test(field): assert exit status and stderr for the output-format cases

Per review feedback: an empty stdout alone could pass if the placeholder
errored out instead of rendering empty.

---------

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-16 11:07:20 +02:00
VXNCXNX 59aecd4f2b
fix: add allow_hyphen_values to --with-nth (#1156)
Matches --nth and --hide-nth behavior to support documented negative indices syntax.

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-16 08:11:27 +00:00
dependabot[bot] fe45ed36b7
chore(deps): bump shell-quote from 0.7.2 to 0.8.0 in the cargo-prod group (#1154)
* chore(deps): bump shell-quote in the cargo-prod group

Bumps the cargo-prod group with 1 update: [shell-quote](https://github.com/allenap/shell-quote).


Updates `shell-quote` from 0.7.2 to 0.8.0
- [Commits](https://github.com/allenap/shell-quote/compare/v0.7.2...v0.8.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>

* test: fix literal failing test

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Loric ANDRE <loric.andre@pm.me>
2026-08-11 13:21:43 +00:00
skim-rs-bot[bot] 01be03f0d5
release: v5.6.4 (#1153)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-10 10:39:35 +00:00
Loric ANDRE 733aaeb148 fix: avoid truncation by casting up instead of down 2026-08-10 12:12:00 +02:00
Loric ANDRE f6a302390c fix: different suseconds and time per platform 2026-08-10 12:10:25 +02:00
Loric ANDRE 5ff1bda145 fix: platform-dependant timevals 2026-08-10 12:05:32 +02:00
Loric ANDRE 070d0849b2 fix(image): detect picker from tty to support protocol detection with piped input 2026-08-10 11:58:44 +02:00
skim-rs-bot[bot] 5fc3b0c1a0
release: v5.6.3 (#1152)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 14:07:35 +02:00
Vimsucks a5bd0fefa6
fix: advance the input cursor by byte length in insert_str (#1151)
`cursor_pos` is a byte offset, as `move_cursor_to` shows by validating it with
`is_char_boundary` and clamping to `value.len()`. `insert_str` advanced it by
`chars().count()` instead, so any multibyte input left the cursor inside the
text that was just inserted.

The single-char `insert` already uses `c.len_utf8()` and was unaffected.

Pasting (or committing from an IME) "中文" left the cursor at byte 3 instead of
6, so the next chunk landed between the two characters: "中文" + "测试" came out
as "中测试文". Pure ASCII input never hit this because there char count equals
byte length.

Co-authored-by: zhuyang <zhuyang@qunhemail.com>
Co-authored-by: LoricAndre <57358788+LoricAndre@users.noreply.github.com>
2026-08-07 11:27:15 +00:00
skim-rs-bot[bot] f3c03000b3
release: v5.6.2 (#1145)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 10:43:53 +00:00
Loric ANDRE 1dead2b3c6 fix: match double-width roman characters (closes #1149) 2026-08-07 12:11:23 +02:00
LoricAndre b17e93890a
fix: reorder batches in --tac mode (#1150)
closes #1148
2026-08-07 10:10:17 +00:00
dependabot[bot] d99d6cecc7
chore(deps): bump the cargo-prod group with 2 updates (#1147)
Bumps the cargo-prod group with 2 updates: [clap](https://github.com/clap-rs/clap) and [frizbee](https://github.com/saghen/frizbee).


Updates `clap` from 4.6.4 to 4.6.5
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.4...clap_complete-v4.6.5)

Updates `frizbee` from 0.11.0 to 0.12.0
- [Commits](https://github.com/saghen/frizbee/compare/v0.11.0...v0.12.0)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
- dependency-name: frizbee
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:23:58 +02:00
Loric ANDRE 7429ca3ec2 fix: assymetry in char_equal causing panic with some unicode characters 2026-08-02 14:39:28 +02:00
dependabot[bot] bc379b5f47
chore(deps): bump the gha-prod group with 3 updates (#1146)
Bumps the gha-prod group with 3 updates: [taiki-e/install-action](https://github.com/taiki-e/install-action), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `taiki-e/install-action` from 2 to 2.85.4
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Commits](https://github.com/taiki-e/install-action/compare/v2...v2.85.4)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 7.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](37930b1c2a...3e5f45b2cf)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.85.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gha-prod
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: gha-prod
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: gha-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 10:25:59 +02:00
Loric ANDRE d2ae57625f docs: preview command runs with sh/cmd, not SHELL 2026-07-28 13:37:43 +02:00
52 changed files with 2135 additions and 582 deletions

View file

@ -49,7 +49,7 @@ jobs:
exit 1
fi
- name: Install cargo-generate-rpm
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-generate-rpm@0.21
- name: Setup cargo cache
@ -73,7 +73,7 @@ jobs:
cp target/generate-rpm/*.rpm target/linux-packages/
ls -l target/linux-packages/
- name: Upload packages
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: artifacts-linux-packages-${{ matrix.arch }}
path: target/linux-packages/*

13
.github/workflows/pr-auto-update.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Pull request update
on:
push:
branches: [master]
jobs:
autoupdate:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: allonsy-studio/actions-pr-auto-update@5fbf9661cbf5c1f092ec33e5a1cfbe70dc5bd81c

View file

@ -88,11 +88,11 @@ jobs:
cache-all-crates: "true"
- name: Generate files
run: |
cargo run -- --man > ./man/man1/sk.1
cargo run -- --shell bash > ./shell/completion.bash
cargo run -- --shell zsh > ./shell/completion.zsh
cargo run -- --shell fish > ./shell/completion.fish
cargo run -- --shell nushell > ./shell/completion.nu
cargo run --locked -- --man > ./man/man1/sk.1
cargo run --locked -- --shell bash > ./shell/completion.bash
cargo run --locked -- --shell zsh > ./shell/completion.zsh
cargo run --locked -- --shell fish > ./shell/completion.fish
cargo run --locked -- --shell nushell > ./shell/completion.nu
- name: Check diff
run: |
if git diff --exit-code; then

View file

@ -26,7 +26,7 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2
- uses: taiki-e/install-action@742a3317eac7bd62f91cd888b4eead5e784ba833 # v2
with:
tool: git-cliff
- id: version
@ -69,7 +69,7 @@ jobs:
git cliff -u -t "v$VERSION" --strip all
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.version.outputs.version != ''
with:
name: release-${{ github.sha }}
@ -86,7 +86,7 @@ jobs:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-${{ github.sha }}
path: ${{ runner.temp }}/release

View file

@ -47,7 +47,7 @@ jobs:
# installs via winget below.
name: Install zellij (Linux/macOS)
if: runner.os != 'Windows'
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@v2.87.1
with:
tool: zellij@0.44.3
- name: Install zellij (Windows)
@ -83,7 +83,7 @@ jobs:
run: rustup toolchain install
- &nextest-install
name: Install nextest
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@v2.87.1
with:
tool: nextest@0.9
- &cache
@ -93,7 +93,7 @@ jobs:
run: cargo test --doc
- name: "Run tests"
# Do not use `--all-targets` to avoid running benches
run: cargo nextest run --release --profile ci --bins --lib --examples --tests
run: cargo nextest run --release --locked --profile ci --bins --lib --examples --tests
env:
LC_ALL: en_US.UTF-8
TERM: xterm-256color
@ -123,14 +123,14 @@ jobs:
- *checkout
- *toolchain
- *nextest-install
- uses: taiki-e/install-action@v2
- uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-llvm-cov@0.8
- *cache
- name: "Run tests with coverage"
# Do not use `--all-targets` to avoid running benches
run: |
cargo +nightly llvm-cov nextest --release --profile ci --branch --bins --lib --examples --tests --no-report
cargo +nightly llvm-cov nextest --locked --release --profile ci --branch --bins --lib --examples --tests --no-report
cargo +nightly llvm-cov report --release --cobertura --output-path coverage.xml
cargo +nightly llvm-cov report --release --html
echo "COVERAGE_PERCENT=$(cargo +nightly llvm-cov report --release | tail -n1 | awk '{ print $13 }')" | tee --append $GITHUB_ENV
@ -186,11 +186,11 @@ jobs:
- *cache
- name: Run clippy with specific features
run: |
cargo clippy --no-default-features -- -Dwarnings
cargo clippy --no-default-features --features cli -- -Dwarnings
cargo clippy --no-default-features --features image -- -Dwarnings
cargo clippy --no-default-features --features listen -- -Dwarnings
cargo clippy --no-default-features --features frizbee -- -Dwarnings
cargo clippy --locked --no-default-features -- -Dwarnings
cargo clippy --locked --no-default-features --features cli -- -Dwarnings
cargo clippy --locked --no-default-features --features image -- -Dwarnings
cargo clippy --locked --no-default-features --features listen -- -Dwarnings
cargo clippy --locked --no-default-features --features frizbee -- -Dwarnings
msrv:
@ -198,11 +198,8 @@ jobs:
steps:
- *checkout
- *toolchain
- uses: taiki-e/install-action@v2
with:
tool: cargo-msrv@0.19
- name: MSRV Verify
run: cargo msrv verify
run: cargo +1.91.0 build --release --locked --all-targets
fuzz:
permissions:
@ -226,7 +223,7 @@ jobs:
# https://rust-fuzz.github.io/book/cargo-fuzz/windows/setup.html).
if: runner.os == 'Windows'
uses: ilammy/msvc-dev-cmd@v1
- uses: taiki-e/install-action@v2
- uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-fuzz@0.13
- name: Run fuzz targets
@ -249,7 +246,7 @@ jobs:
done
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: fuzz-crashes-${{ runner.os }}
path: fuzz/artifacts/
@ -268,12 +265,19 @@ jobs:
- *checkout
- *toolchain
- *cache
- uses: taiki-e/install-action@v2
- uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-public-api@0.52
- run: rustup toolchain install nightly-x86_64-unknown-linux-gnu
name: Install nightly toolchain
- run: cargo public-api diff latest --deny removed --deny changed
# Compare two repository revisions so cargo-public-api uses their committed
# lockfiles. A registry comparison resolves the released crate again and can
# fail when a new, broken transitive dependency is published.
- run: git fetch --force --tags
name: Fetch release tags
- run: |
latest_tag="$(git tag --list 'v*' --sort=-version:refname | head -n1)"
cargo public-api diff "${latest_tag}..HEAD" --deny removed --deny changed
name: Run cargo-public-api

View file

@ -34,7 +34,6 @@
- Changing the threading model or synchronization primitives
- Adding or removing public API surface (`SkimItem`, `SkimOptions`, `SkimOutput`, etc.)
- Changing the event/action system or key binding infrastructure
- Keep call-site line numbers in the cross-reference table up to date when the referenced functions move.
## Testing

View file

@ -428,7 +428,8 @@ Source (stdin bytes or child process stdout)
└── parallel_bufread() (all inputs)
├─ Thread 1: I/O reader — reads 256 KB chunks, splits at line boundaries,
│ assigns monotonic sequence numbers, sends to MPMC channel
├─ Thread N: workers — receive chunks, validate UTF-8,
├─ Thread 1: bounded dispatcher — uses in-flight tokens to limit queued/running jobs
├─ Pool workers: receive chunk jobs, validate UTF-8,
│ create DefaultSkimItem::new(line, ansi, trans_fields, matching_fields, delimiter)
│ .hidden_fields(hidden_fields, delimiter)
│ (handles ANSI stripping, --nth / --with-nth / --hide-nth inline),
@ -491,7 +492,7 @@ Engines are composable through the factory pattern. Starting from `Matcher::crea
options
├── if regex mode:
│ RegexEngineFactory
│ RegexEngineFactory (configured with the same RankBuilder / --tiebreak criteria)
│ └─ if normalize: NormalizedEngineFactory(RegexEngineFactory)
└── else (fuzzy/exact mode):
@ -581,12 +582,12 @@ Matcher::run(query, item_pool, thread_pool, …)
│ └─ merge_worker_results(worker_results, no_sort, …)
│ ├─ concatenate k sorted runs
│ ├─ sort() (stable; driftsort detects k runs → O(n log k))
│ └─ write into SpinLock<Option<ProcessedItems>>
│ └─ validate query generation and write into Mutex<Option<ProcessedItems>>
└─ stopped.store(true)
```
Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before processing. `MatcherControl::kill()` sets `interrupt = true`; `MatcherControl::drop()` also calls `kill()`.
Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before processing. `MatcherControl::kill()` sets `interrupt = true`; `MatcherControl::drop()` also calls `kill()`. Each forced restart increments a shared query generation. Published `ProcessedItems` carry that generation; both publication and consumption reject stale generations, so a cancelled matcher cannot replace newer results.
### Ranking & Sorting
@ -611,7 +612,10 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
| --- | --- |
| `Replace` | Fresh match pass (query changed, full re-sort) |
| `SortedMerge` | New items arrived during a running match (merge-insert) |
| `Append` | `--no-sort` mode |
| `Append` | Incremental `--no-sort` mode |
| `Prepend` | Incremental `--tac --no-sort` mode, preserving global reverse-input order |
`Rank::index` always records the item's stable ordinal in the original input stream. `RankBuilder` makes both configured index criteria and the implicit final index tiebreak descending under `--tac`, so normal `SortedMerge` remains valid across independently reversed batches.
---
@ -624,7 +628,8 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
- A `tokio::sync::mpsc` channel (`event_tx` / `event_rx`) of capacity 1 M for events.
- A `JoinHandle` for a background Tokio task that reads `crossterm::event::EventStream` and sends `Event` values.
- A `CancellationToken` to stop the background task.
- A `is_fullscreen` flag that determines the `ratatui::Viewport`.
- An `is_fullscreen` flag that determines the `ratatui::Viewport`.
- The fixed viewport `Rect` for inline mode, which can move when the terminal scrolls.
**Viewport selection** (`Tui::new_with_height_and_backend()`):
@ -633,7 +638,7 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
- `Size::Percent(p)` → fixed viewport with `terminal_height * p / 100` rows.
- `Size::Neg(lines)` → fixed viewport with `terminal_height - lines` rows, saturating at zero.
Any fixed viewport is anchored at the current cursor position; the terminal is scrolled if needed to make room.
Any fixed viewport is anchored at the current cursor position; the terminal is scrolled if needed to make room. After construction, `Tui::min_height()` can increase an inline viewport to `--min-height`. It limits the height to the terminal height and scrolls the terminal before it moves and resizes the viewport when there are too few rows below it.
The default backend is `CrosstermBackend<BufWriter<Stderr>>`. Skim always draws to **stderr** so stdout remains clean for piped output.
@ -735,6 +740,7 @@ restart_matcher(force)
├─ kill existing matcher_control
├─ determine MergeStrategy
│ ├─ Replace → if query changed / force
│ ├─ Prepend → incremental --tac --no-sort
│ └─ SortedMerge / Append otherwise
└─ matcher.run(query, pool, thread_pool, processed_items, strategy, no_sort, needs_render)
→ returns new MatcherControl
@ -823,17 +829,19 @@ The `StatusInfo` struct rendered inside the input line shows:
`ItemList` (`src/tui/item_list.rs`) maintains:
- `items: Vec<MatchedItem>` — the currently displayed matched items
- `processed_items: Arc<SpinLock<Option<ProcessedItems>>>` — shared with matcher
- `processed_items: Arc<Mutex<Option<ProcessedItems>>>` — shared with matcher
- `matcher_generation: Arc<AtomicUsize>` — identifies the active query generation
- `selection: Vec<usize>` — indices of multi-selected items
- `current: usize` — focused item index (0 = bottom in default layout)
- `offset: usize` — scroll offset (number of items scrolled)
- `manual_hscroll: i16` — user-driven horizontal scroll
On each render, `ItemList::render()` checks `processed_items` and swaps them in atomically via the `SpinLock`. Depending on `MergeStrategy`:
On each render, `ItemList::render()` checks `processed_items`, rejects results from an old query generation, and swaps current results in through the mutex. Depending on `MergeStrategy`:
- `Replace`: replaces `items` entirely.
- `SortedMerge`: performs an O(n+m) merge preserving order.
- `Append`: extends `items`.
- `Prepend`: places a reversed incremental `--tac` batch before existing items; the cursor follows the head unless the user moved away from it.
**Selection state management:**
@ -862,7 +870,7 @@ Pre-selection is applied when items first appear: `DefaultSkimSelector::should_s
`Preview` (`src/tui/preview.rs`) renders a side/top/bottom pane showing expanded information about the focused item. Its stored content is one of three variants:
**Plain text mode** (no `pty`): spawns `sh -c <cmd>` on Unix or `cmd /c <cmd>` on Windows. On Windows, `Command::raw_arg` is used so `cmd.exe` receives shell metacharacters exactly as written. The child captures stdout (capped at `PREVIEW_MAX_BYTES`), parses it with `ansi_to_tui::IntoText`, stores as `PreviewContent::Text`, and sends `Event::PreviewReady`.
**Plain text mode** (no `pty`): spawns `sh -c <cmd>` on Unix or `cmd /c <cmd>` on Windows. On Windows, `Command::raw_arg` is used so `cmd.exe` receives shell metacharacters exactly as written. The worker drains stdout and stderr concurrently, but retains at most `PREVIEW_MAX_BYTES` from each stream. Retained stdout is parsed with `ansi_to_tui::IntoText` and published while the command runs. Cancellation terminates the child process group (the process tree on Windows) and invalidates its output writer, so an old reader cannot replace content from a newer preview. At exit, successful stdout or failed stderr is stored as `PreviewContent::Text` and followed by `Event::PreviewReady`.
**PTY mode** (`--preview-window pty`): creates a real pseudo-terminal pair via `portable_pty`. The child process sees a properly sized terminal (via `ROWS`/`COLUMNS` env and PTY dimensions). Output is parsed by a `vt100::Parser` with a scrollback buffer, stored as `PreviewContent::Terminal(Arc<RwLock<vt100::Parser>>)`. This enables interactive preview programs (e.g. `bat`, `delta`).
@ -887,12 +895,14 @@ else if pty mode:
→ Event::PreviewReady when EOF
else:
sh -c <cmd>
thread: wait for output → content.write() = PreviewContent::Text(…)
start shell in a dedicated process group with piped stdout + stderr
thread: drain both streams with bounded retention; stream active stdout; poll child status
→ cancellation invalidates the writer and kills the process group
→ content.write() = PreviewContent::Text(…)
→ Event::PreviewReady
```
Scroll state: `scroll_y`, `scroll_x` (in lines/columns). `page_up/down`, `scroll_up/down/left/right` modify these. `PreviewPosition` supports fixed, percentage, and negative offsets. When `PreviewReady` fires, an optional offset expression (from `--preview-window +expr`) is evaluated to auto-scroll to the matched line.
Scroll state: `scroll_y`, `scroll_x` (in lines/columns) and `total_lines` use `usize`; conversion to ratatui's `u16` coordinates saturates at render time. `page_up/down`, `scroll_up/down/left/right` modify these. `PreviewPosition` supports fixed, percentage, and negative offsets. When `PreviewReady` fires, an optional offset expression (from `--preview-window +expr`) is evaluated to auto-scroll to the matched line.
### Header Widget
@ -1269,21 +1279,23 @@ Main thread (Tokio runtime)
└─ Tokio task: Tui event pump (crossterm EventStream + tick timer)
ThreadPool (N = num_cpus OS threads, persistent)
├─ Matcher coordinator (1 slot per match run)
└─ Worker threads (N-1 slots per match run)
Matcher ThreadPool (persistent)
└─ Worker threads process atomic match chunks; one separate coordinator thread waits for completion
Reader threads (OS threads, per-invocation):
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 5ms), calls ItemPool::append
Reader ThreadPool (persistent)
└─ Short chunk jobs parse items; an in-flight token limit bounds the work queue
Reader threads (OS threads, per invocation):
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 1ms), calls ItemPool::append
├─ I/O reader thread: reads large byte chunks, splits lines, assigns sequence numbers
├─ Worker threads (N): parse lines, create DefaultSkimItem (ANSI strip + field transforms inline)
├─ Bounded dispatcher thread: submits chunk jobs only while an in-flight token is available
├─ Reorder thread: sequence-ordered output; drops tx_pipeline_done on EOF
└─ Killer thread (command inputs only): waits for rx_interrupt or rx_pipeline_done;
kills child process when either fires
└─ Killer thread: waits for rx_interrupt or rx_pipeline_done; kills a command child if present
Preview thread (OS thread, per preview spawn):
└─ reads PTY/child stdout or decodes image path → PreviewContent Arc<RwLock>
→ sends Event::PreviewReady
Preview threads (OS threads, per preview spawn):
├─ PTY reader, image decoder, or plain-child monitor
└─ Plain mode also has bounded stdout and stderr drain threads
→ writes PreviewContent Arc<RwLock> and sends Event::PreviewReady
IPC handler task (Tokio, per connection):
└─ reads RON actions → sends Event::Action to TUI channel
@ -1294,9 +1306,9 @@ Popup stdin relay thread (OS thread, only in --popup/--tmux mode):
**Synchronization primitives used:**
- `Arc<SpinLock<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff
- `Arc<Mutex<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff without CPU-spinning under merge contention
- `Arc<AtomicBool>``needs_render` (matcher → event loop), `stopped` / `interrupt` (MatcherControl)
- `Arc<AtomicUsize>``processed` / `matched` counters, reader `components_to_stop`
- `Arc<AtomicUsize>``processed` / `matched` counters, matcher query generation, reader `components_to_stop`
- `Arc<tokio::sync::Notify>``items_available` (ItemPool → Skim::tick wakeup)
- `Arc<std::sync::RwLock<PreviewContent>>` — preview thread → Preview widget
- `kanal::Sender/Receiver<Vec<Arc<dyn SkimItem>>>` — item batches through pipeline
@ -1305,64 +1317,6 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
---
## Important Call Sites (Cross-Reference)
| Call site | File | What it does |
| --- | --- | --- |
| `Skim::run_with` | `src/skim.rs:70` | Top-level library entry point |
| `Skim::run_items` | `src/skim.rs:112` | Convenience wrapper for iterator inputs |
| `Skim::init_tui` | `src/skim.rs:136` | Initialize default crossterm TUI backend |
| `Skim::init` | `src/skim.rs:155` | Constructs all subsystems from options |
| `Skim::start` | `src/skim.rs:199` | Starts reader + initial matcher pass |
| `Skim::handle_reload` | `src/skim.rs:231` | Kills reader, clears pool, restarts |
| `Skim::init_tui_with` | `src/skim.rs:303` | Install a caller-provided TUI backend |
| `Skim::enter` | `src/skim.rs:390` | Enter terminal, resolve image picker, start listener/event pump |
| `Skim::should_enter` | `src/skim.rs:434` | Filter/select-1/exit-0/sync gate |
| `Skim::output` | `src/skim.rs:539` | Collect & return SkimOutput |
| `Skim::tick` | `src/skim.rs:620` | Single async event loop iteration |
| `App::from_options` | `src/tui/app.rs:289` | Build all widgets from options |
| `App::run_preview` | `src/tui/app.rs:503` | Expand cmd, debounce, call Preview::spawn |
| `App::handle_event` | `src/tui/app.rs:628` | Dispatch all Event variants |
| `App::handle_action` | `src/tui/app.rs:833` | Apply action follow-up bindings |
| `App::dispatch_conditional` | `src/tui/app.rs:852` | Dispatch the selected conditional subaction chain without follow-up bindings |
| `App::dispatch_action` | `src/tui/app.rs:875` | Dispatch one Action variant without follow-up bindings |
| `Tui::run_execute` | `src/tui/backend.rs:353` | Suspend reader, run `execute` child with its own tty stdin, restart reader |
| `App::restart_matcher` | `src/tui/app.rs:1352` | Kill old match pass, start new one |
| `App::expand_cmd` | `src/tui/app.rs:1428` | Substitute `{}`, `{q}`, `{n}` etc. |
| `App::handle_mouse` | `src/tui/app.rs:1496` | Handle mouse behavior and emit `double-click` |
| `Widget::render (App)` | `src/tui/app.rs:151` | Root render; calls all sub-widgets |
| `Matcher::run` | `src/matcher.rs:~260` | Parallel match dispatch |
| `merge_worker_results` | `src/matcher.rs:28` | Merge k sorted runs → ProcessedItems |
| `ItemPool::append` | `src/item.rs:469` | Add items, notify matcher |
| `ItemPool::take` | `src/item.rs:502` | Take un-matched items for matcher |
| `DefaultSkimItem::new` | `src/helper/item.rs:64` | ANSI strip, field transform, matching ranges (hidden ranges set later via `hidden_fields` builder) |
| `SkimItemReader::parallel_bufread` | `src/helper/item_reader.rs:287` | Unified parallel pipeline (all inputs) |
| `spawn_io_reader` | `src/helper/item_reader.rs:378` | I/O reader thread: chunk reads + line splitting |
| `spawn_reorder_thread` | `src/helper/item_reader.rs:483` | Reorder thread: ordered output + pipeline-done signal |
| `Preview::spawn` | `src/tui/preview.rs:319` | Start image, PTY, or plain preview worker |
| `Tui::new_with_height_and_backend` | `src/tui/backend.rs:78` | Terminal init + viewport sizing |
| `Tui::enter` | `src/tui/backend.rs:127` | Enable raw mode + terminal setup |
| `Tui::start` | `src/tui/backend.rs:238` | Spawn crossterm EventStream task (fresh cancellation token each call) |
| `Tui::stop_and_join` | `src/tui/backend.rs:224` | Cancel event pump and block until `EventStream` is dropped (before `execute`) |
| `Tui::force_full_redraw` | `src/tui/backend.rs:205` | Reset ratatui diff buffers for a full repaint with no cursor query (after `execute`) |
| `popup::run_with` | `src/popup/mod.rs:86` | Delegate to multiplexer popup + parse output |
| `popup::check_env` | `src/popup/mod.rs:72` | Guard: multiplexer present and not already in popup |
| `check_and_run_popup` | `src/bin/main.rs:131` | Check popup conditions, dispatch to popup::run_with |
| `sk_main` | `src/bin/main.rs:144` | CLI orchestration + output printing |
| `SkimEvent` | `src/binds.rs:25` | Bindable synthetic events, including `double-click`, routed through reserved `KeyEvent`s |
| `parse_key` | `src/binds.rs:226` | `"ctrl-a"``KeyEvent` |
| `parse_action_binds` | `src/binds.rs:335` | `"reload:first"`, `"act-up:suppress+down"` → action follow-up map |
| `parse_action_chain` | `src/binds.rs:383` | `"down+select"``Vec<Action>` |
| `Action::name` | `src/tui/actions.rs:254` | `Action` → canonical bind name (generated from the `define_action_catalog!` list that also defines the `Action` enum and `parse_action`) |
| `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:189` | Build engine factory chain from options |
| `ExactOrFuzzyEngineFactory::create_engine_with_case` | `src/engine/factory.rs:93` | Parse query prefixes, build engine |
| `AndOrEngineFactory::parse_andor` | `src/engine/factory.rs:176` | Split query into AND/OR tree |
| `FuzzyEngine::match_item` | `src/engine/fuzzy.rs:175` | Fuzzy match a single item |
| `LayoutTemplate::from_options` | `src/tui/layout.rs:76` | Compute widget constraint tree |
| `LayoutTemplate::apply` | `src/tui/layout.rs:165` | Split Rect into AppLayout |
| `ItemRenderer::render_item` | `src/tui/item_renderer.rs:84` | Full per-item render pipeline |
| `ColorTheme::init_from_options` | `src/theme.rs:56` | Parse `--color` spec |
---
## Public Library API

View file

@ -5,6 +5,92 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.7.0] - 2026-09-08
### 🐛 Bug Fixes
- Stream preview command instead of waiting for completion (#1176)
### 📚 Documentation
- Update benchmarks
### 🤖 CI
- Lock everything to make sure dependencies match (#1170)
### ⚙️ Miscellaneous Tasks
- Nitpicks after multithreading review (#1172)
## [5.6.7] - 2026-09-04
### 🐛 Bug Fixes
- Make min-height work again (#1168)
### ⚙️ Miscellaneous Tasks
- Update dependencies after tinyvec breakage
## [5.6.6] - 2026-08-22
### 🐛 Bug Fixes
- *(matcher)* An inverse query only checks the first --nth field (#1159)
- Do not feed items through a fifo in zsh completions (#1164)
- *(filter)* --filter hangs when the query is below --min-query-length (#1158)
- Path_name_offset returns bytes while Rank::begin is a char index (#1160)
### 🤖 CI
- Automatically update PRs on pushes to master
- Fix auto update branch
### ⚙️ Miscellaneous Tasks
- Clippy & fmt after cargo update
## [5.6.5] - 2026-08-16
### 🐛 Bug Fixes
- Add allow_hyphen_values to --with-nth (#1156)
- *(field)* An out-of-range field index silently matches field 1 (#1155)
### New Contributors
* @VXNCXNX made their first contribution in [#1155](https://github.com/skim-rs/skim/pull/1155)
## [5.6.4] - 2026-08-10
### 🐛 Bug Fixes
- *(image)* Detect picker from tty to support protocol detection with piped input
- Platform-dependant timevals
- Different suseconds and time per platform
- Avoid truncation by casting up instead of down
## [5.6.3] - 2026-08-07
### 🐛 Bug Fixes
- Advance the input cursor by byte length in insert_str (#1151)
### New Contributors
* @vimsucks made their first contribution in [#1151](https://github.com/skim-rs/skim/pull/1151)
## [5.6.2] - 2026-08-07
### 🐛 Bug Fixes
- Assymetry in char_equal causing panic with some unicode characters
- Reorder batches in `--tac` mode (#1150)
- Match double-width roman characters (closes #1149)
### 📚 Documentation
- Preview command runs with sh/cmd, not SHELL
## [5.6.1] - 2026-07-27
### 🤖 CI

441
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,10 @@
[package]
name = "skim"
version = "5.6.1"
version = "5.7.0"
authors = ["Loric ANDRE", "Zhang Jinzhou <lotabout@gmail.com>"]
description = "Fuzzy Finder in rust!"
documentation = "https://docs.rs/skim"
homepage = "https://github.com/skim-rs/skim"
repository = "https://github.com/skim-rs/skim"
readme = "README.md"
keywords = ["fuzzy", "menu", "util"]
license = "MIT"
edition = "2024"
@ -51,6 +49,13 @@ inherits = "release"
debug = true
strip = false
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "unpacked"
[profile.dev.package."*"]
debug = false
[lints.rust]
missing_docs = "warn"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage, coverage_nightly)'] }
@ -73,7 +78,7 @@ crossterm = { version = ">=0.0.0", features = ["event-stream", "use-dev-tty", "l
derive_builder = "0.20.2"
derive_more = { version = "2.1.1", features = ["debug", "eq"] }
env_logger = { version = "0.11.10", optional = true, features = ["humantime"] }
frizbee = { version = "=0.11.0", optional = true }
frizbee = { version = "=0.13.0", optional = true }
futures = "0.3.32"
gungraun = { version = "0.19.1", optional = true }
image = { version = "0.25.10", optional = true, default-features = false, features = ["png", "jpeg", "gif", "webp"] }
@ -83,7 +88,7 @@ kanal = "0.1.1"
log = "0.4.31"
memchr = "2.8.1"
mimalloc = { version = "0.1.48", features = ["v3"] }
nix = { version = "0.31.3", features = ["fs", "poll"] }
nix = { version = "0.31.3", features = ["fs", "poll", "signal"] }
portable-pty = "0.9.0"
ratatui = "0.30.0"
ratatui-image = { version = "11.0.4", features = ["crossterm"], default-features = false, optional = true }
@ -91,7 +96,7 @@ regex = "1.12.3"
roff = "1.1.1"
ron = { version = "0.12.1", optional = true }
serde = { version = "1.0.228", features = ["derive"], optional = true }
shell-quote = "0.7.2"
shell-quote = "0.8.0"
shlex = { version = "2.0.1", optional = true }
tempfile = "3.27.0"
thiserror = "2.0.18"

BIN
bench.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 181 KiB

View file

@ -1462,8 +1462,8 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
let y_hi = mx * 2.0;
let axes = fg.axes2d();
axes.set_title("Total Time", lbl)
.set_x_label("Items", lbl)
.set_y_label("Time (s)", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Time (s, log)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_y_log(Some(10.0))
@ -1487,7 +1487,7 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
let y_hi = (mx * 1.25).max(100.0);
let axes = fg.axes2d();
axes.set_title("Peak CPU", lbl)
.set_x_label("Items", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("CPU (%)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
@ -1506,16 +1506,18 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
// ── Panel 2: Peak Memory — log x, linear y ───────────────────────────────
{
let (_, mx) = y_extent(&mem_bands);
let (mn, mx) = y_extent(&mem_bands);
let y_lo = (mn * 0.5).max(1e-9);
let y_hi = (mx * 1.25).max(1.0);
let axes = fg.axes2d();
axes.set_title("Peak Memory", lbl)
.set_x_label("Items", lbl)
.set_y_label("Memory (MB)", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Memory (MB, log)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_y_log(Some(10.0))
.set_x_range(Fix(x_lo), Fix(x_hi))
.set_y_range(Fix(0.0), Fix(y_hi))
.set_y_range(Fix(y_lo), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
.set_legend(
@ -1533,7 +1535,7 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
let y_hi = (mx * 1.25).max(0.01);
let axes = fg.axes2d();
axes.set_title("Startup Time", lbl)
.set_x_label("Items", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Time (s)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))

2
fuzz/Cargo.lock generated
View file

@ -1613,7 +1613,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skim"
version = "5.3.2"
version = "5.6.3"
dependencies = [
"ansi-to-tui",
"assert_enum_variants",

View file

@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH sk 1 "sk 5.6.1"
.TH sk 1 "sk 5.7.0"
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH NAME
@ -346,10 +346,11 @@ Can either be a row count or a percentage A negative row count will use term hei
Disable height (force full screen)
.TP
\fB\-\-min\-height\fR \fI<MIN_HEIGHT>\fR [default: 10]
Minimum height of skim\*(Aqs window
Minimum height of skim\*(Aqs window as a non\-negative row count
Useful when the height is set as a percentage
Ignored when \-\-height is not specified
Must be a non\-negative row count, not a percentage.
Useful when the height is set as a percentage.
Ignored when \-\-height is not specified.
.TP
\fB\-\-margin\fR \fI<MARGIN>\fR [default: 0]
Screen margin
@ -514,9 +515,9 @@ Maximum number of query history entries to keep
\fB\-\-preview\fR \fI<PREVIEW>\fR
Preview command
Execute the given command for the current line and display the result on the preview window. {} in the command
is the placeholder that is replaced to the single\-quoted string of the current line. To transform the
replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
Execute the given command with `sh \-c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
`{}` in the command is the placeholder that is replaced to the single\-quoted string of the current line.
To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
**Examples**:
@ -1335,4 +1336,4 @@ When using `sk \-\-remote`, pipe in action chains (see the KEYBINDS section), fo
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH VERSION
v5.6.1
v5.7.0

View file

@ -38,7 +38,7 @@ complete -c sk -l layout -d 'Set layout' -r -f -a "default\t'Display from the bo
reverse\t'Display from the top of the screen'
reverse-list\t'Display from the top of the screen, prompt at the bottom'"
complete -c sk -l height -d 'Height of skim\'s window' -r
complete -c sk -l min-height -d 'Minimum height of skim\'s window' -r
complete -c sk -l min-height -d 'Minimum height of skim\'s window as a non-negative row count' -r
complete -c sk -l margin -d 'Screen margin' -r
complete -c sk -s p -l prompt -d 'Set prompt' -r
complete -c sk -l cmd-prompt -d 'Set prompt in command mode' -r

View file

@ -79,7 +79,7 @@ module completions {
--reverse # Shorthand for reverse layout
--height: string # Height of skim's window
--no-height # Disable height (force full screen)
--min-height: string # Minimum height of skim's window
--min-height: string # Minimum height of skim's window as a non-negative row count
--margin: string # Screen margin
--prompt(-p): string # Set prompt
--cmd-prompt: string # Set prompt in command mode

View file

@ -49,7 +49,7 @@ history\:"History scheme\: will force index as the first tiebreak"))' \
reverse\:"Display from the top of the screen"
reverse-list\:"Display from the top of the screen, prompt at the bottom"))' \
'--height=[Height of skim'\''s window]:HEIGHT:_default' \
'--min-height=[Minimum height of skim'\''s window]:MIN_HEIGHT:_default' \
'--min-height=[Minimum height of skim'\''s window as a non-negative row count]:MIN_HEIGHT:_default' \
'--margin=[Screen margin]:MARGIN:_default' \
'-p+[Set prompt]:PROMPT:_default' \
'--prompt=[Set prompt]:PROMPT:_default' \

View file

@ -267,12 +267,6 @@ _skim_dir_completion() {
"" "/" ""
}
_skim_feed_fifo() (
command rm -f "$1"
mkfifo "$1"
cat <&0 > "$1" &
)
_skim_complete() {
setopt localoptions ksh_arrays
# Split arguments around --
@ -296,20 +290,17 @@ _skim_complete() {
rest=("$@")
fi
local fifo lbuf cmd matches post
fifo="${TMPDIR:-/tmp}/skim-complete-fifo-$$"
local lbuf cmd matches post
lbuf=${rest[0]}
cmd=$(__skim_extract_command "$lbuf")
post="${funcstack[1]}_post"
type $post > /dev/null 2>&1 || post=cat
_skim_feed_fifo "$fifo"
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" < "$fifo" | $post | tr '\n' ' ')
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" | $post | tr '\n' ' ')
if [ -n "$matches" ]; then
LBUFFER="$lbuf$matches"
fi
zle reset-prompt
command rm -f "$fifo"
}
_skim_complete_telnet() {

View file

@ -1 +1 @@
5.6.1
5.7.0

View file

@ -79,24 +79,33 @@ impl MatchEngine for ExactEngine {
let mut matched_result = None;
let item_text = item.text();
let default_range = [(0, item_text.len())];
for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
let start = min(start, item_text.len());
let end = min(end, item_text.len());
if self.query_regex.is_none() {
matched_result = Some((0, 0));
break;
let ranges = item.get_matching_ranges().unwrap_or(&default_range);
if ranges.is_empty() {
// Nothing to match against (e.g. every `--nth` index is out of range): the item
// stays unmatched, inverse or not.
} else if self.query_regex.is_none() {
matched_result = Some((0, 0));
} else {
for &(start, end) in ranges {
let start = min(start, item_text.len());
let end = min(end, item_text.len());
matched_result =
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
if matched_result.is_some() {
break;
}
}
matched_result =
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
// An inverse query has to be evaluated over *all* the matching ranges: the item
// only matches when none of them contains the query. Inverting inside the loop
// would let the first non-matching field short-circuit the scan and wrongly
// accept an item whose later fields do contain the query.
if self.inverse {
matched_result = matched_result.xor(Some((0, 0)));
}
if matched_result.is_some() {
break;
}
}
let (begin, end) = matched_result?;

View file

@ -83,6 +83,83 @@ fn inverse_match_excludes_query() {
assert!(e.match_item(&"foo".to_string()).is_none());
}
/// An item exposing explicit matching ranges, as `--nth` produces.
struct RangedItem {
text: String,
ranges: Vec<(usize, usize)>,
}
impl SkimItem for RangedItem {
fn text(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.text)
}
fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
Some(&self.ranges)
}
}
#[test]
fn inverse_match_checks_every_matching_range() {
// `--nth 1,2` over "foo bar" yields two ranges: "foo" and "bar". An inverse
// query `!foo` must reject the item because one of the ranges contains "foo",
// even though the *first* range scanned may not.
let e = engine(
"foo",
ExactMatchingParam {
inverse: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
let foo_in_first_range = RangedItem {
text: "foo bar".to_string(),
ranges: vec![(0, 3), (4, 7)],
};
assert!(
e.match_item(&foo_in_first_range).is_none(),
"item whose first field contains the query must not match an inverse query"
);
let foo_in_second_range = RangedItem {
text: "bar foo".to_string(),
ranges: vec![(0, 3), (4, 7)],
};
assert!(
e.match_item(&foo_in_second_range).is_none(),
"item whose second field contains the query must not match an inverse query"
);
let no_foo = RangedItem {
text: "bar baz".to_string(),
ranges: vec![(0, 3), (4, 7)],
};
assert!(
e.match_item(&no_foo).is_some(),
"item where no field contains the query must match an inverse query"
);
}
#[test]
fn inverse_match_with_no_matching_range_does_not_match() {
// Every `--nth` index out of range leaves the item with no range at all;
// there is nothing to match against, so the item stays unmatched.
let e = engine(
"foo",
ExactMatchingParam {
inverse: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
let item = RangedItem {
text: "bar baz".to_string(),
ranges: vec![],
};
assert!(e.match_item(&item).is_none());
}
#[test]
fn empty_query_matches_everything() {
let e = engine("", ExactMatchingParam::default());

View file

@ -23,6 +23,14 @@ pub enum FieldRange {
Both(i32, i32),
}
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
/// single failure mode is overflowing `i32`; saturate instead of silently falling
/// back to a different field.
fn parse_index(s: &str) -> i32 {
s.parse()
.unwrap_or(if s.starts_with('-') { i32::MIN } else { i32::MAX })
}
impl FieldRange {
/// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10")
#[allow(clippy::should_implement_trait)]
@ -32,8 +40,8 @@ impl FieldRange {
// "1", "1..", "..10", "1..10", etc.
let opt_caps = FIELD_RANGE.captures(range);
if let Some(caps) = opt_caps {
let opt_left = caps.name("left").map(|s| s.as_str().parse().unwrap_or(1));
let opt_right = caps.name("right").map(|s| s.as_str().parse().unwrap_or(-1));
let opt_left = caps.name("left").map(|s| parse_index(s.as_str()));
let opt_right = caps.name("right").map(|s| parse_index(s.as_str()));
let opt_sep = caps.name("sep").map(|s| s.as_str().to_string());
match (opt_left, opt_right) {

View file

@ -19,6 +19,24 @@ fn test_parse_range() {
assert_eq!(FieldRange::from_str("a..b"), None);
}
#[test]
fn test_parse_range_out_of_i32_range() {
// Indices past i32 saturate instead of silently falling back to field 1 / -1.
assert_eq!(FieldRange::from_str("2147483648"), Some(Single(i32::MAX)));
assert_eq!(FieldRange::from_str("-2147483649"), Some(Single(i32::MIN)));
assert_eq!(FieldRange::from_str("99999999999.."), Some(RightInf(i32::MAX)));
assert_eq!(FieldRange::from_str("..99999999999"), Some(LeftInf(i32::MAX)));
assert_eq!(FieldRange::from_str("2..99999999999"), Some(Both(2, i32::MAX)));
// ...and a saturated index still resolves to nothing on a short line.
assert_eq!(
FieldRange::from_str("2147483648").unwrap().to_index_pair(3),
Single(i32::MAX).to_index_pair(3)
);
assert_eq!(FieldRange::from_str("2147483648").unwrap().to_index_pair(3), None);
assert_eq!(FieldRange::from_str("-2147483649").unwrap().to_index_pair(3), None);
}
use regex::Regex;
#[test]

View file

@ -1,6 +1,7 @@
//! Byte/Char helpers
use super::Score;
use super::constants::SEPARATOR_TABLE;
use crate::fuzzy_matcher::util::char_equal;
use memchr::{memchr, memrchr};
pub(super) trait Atom: PartialEq + Into<char> + Copy {
@ -112,9 +113,15 @@ impl Atom for u8 {
}
impl Atom for char {
#[inline(always)]
fn eq_ignore_case(self, b: Self) -> bool {
self.to_lowercase().eq(b.to_lowercase())
fn eq(self, other: Self, respect_case: bool) -> bool {
char_equal(self, other, respect_case)
}
#[inline(always)]
fn eq_ignore_case(self, b: Self) -> bool {
char_equal(self, b, false)
}
#[inline(always)]
fn is_lowercase(self) -> bool {
self.is_lowercase()
@ -179,6 +186,8 @@ mod tests {
fn char_atom_eq_and_case() {
assert!('a'.eq('A', false));
assert!(!'a'.eq('A', true));
assert!(''.eq('a', true));
assert!(''.eq('a', false));
assert!('a'.is_lowercase());
assert!(!'A'.is_lowercase());
// Default (non-SIMD) find impls for char.

View file

@ -28,18 +28,14 @@ pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
) -> Option<BandingInfo> {
let n = pat.len();
let m = cho.len();
let row_bounds;
let j_first;
if ALLOW_TYPOS {
j_first = find_first_char(pat, cho, respect_case)?;
row_bounds = None;
let (j_first, row_bounds) = if ALLOW_TYPOS {
(find_first_char(pat, cho, respect_case)?, None)
} else {
let fm = compute_first_match_cols(pat, cho, respect_case)?;
let lm = compute_last_match_cols(pat, cho, respect_case)?;
j_first = fm[0];
row_bounds = Some(compute_row_col_bounds(n, m, &fm, &lm));
}
(fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
};
let bandwidth = if ALLOW_TYPOS { n + TYPO_BAND_SLACK } else { 0 };
let min_true_matches = if ALLOW_TYPOS { n.div_ceil(2) } else { 0 };

View file

@ -140,13 +140,14 @@ impl FuzzyMatcher for ClangdMatcher {
let mut row = num_pattern_chars;
let mut col = num_choice_chars;
while row > 0 || col > 0 {
if last_action == Action::Match {
indices_reverse.push((col - 1) as IndexType);
}
while col > 0 {
let cell = &dp[row][col];
if last_action == Action::Match {
if row == 0 {
debug_assert!(false, "clangd backtracking hit a match with no pattern left");
break;
}
indices_reverse.push((col - 1) as IndexType);
last_action = cell.last_action_match;
row -= 1;
col -= 1;

View file

@ -85,4 +85,53 @@ mod tests {
// Empty pattern yields an empty index list, so begin/end fall back to 0.
assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
}
/// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
///
/// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
/// asymmetric for such characters. `cheap_matches` compares
/// `(choice, pattern)` while the matchers' `allow_match` helpers compare
/// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
/// then refused to match. The clangd matcher's backtracking loop walked off
/// the start of its matrix, panicking with "attempt to subtract with
/// overflow" in debug and an out-of-bounds index in release.
#[test]
fn multichar_lowercase_does_not_panic() {
use crate::fuzzy_matcher::clangd::ClangdMatcher;
use crate::fuzzy_matcher::fzy::FzyMatcher;
use crate::fuzzy_matcher::skim::SkimMatcherV2;
let skim = SkimMatcherV2::default();
let fzy = FzyMatcher::default();
let clangd = ClangdMatcher::default();
let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
// The exact crashing input from the fuzz artifact, plus related shapes.
let cases = [
("Jİ:I", "İ:İ"),
("I", "İ"),
("İ", "I"),
("i", "İ"),
("İ", "i"),
("Jİ:Iİ", "İİ"),
("straße", "STRASSE"),
("ffly", "ffl"),
];
for (choice, pattern) in cases {
let num_chars = choice.chars().count();
for (name, matcher) in matchers {
// Must not panic, and any returned index must be a valid char
// index into `choice` (the invariant asserted by the fuzzer).
if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
for idx in indices {
assert!(
idx < num_chars,
"{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
);
}
}
}
}
}
}

View file

@ -27,6 +27,15 @@ fn test_match_or_not() {
);
}
#[test]
fn fullwidth_ascii_matches_ascii_query() {
let matcher = SkimMatcherV2::default();
let (_, indices) = matcher
.fuzzy_indices("", "abc")
.expect("fullwidth ASCII should match");
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn test_match_quality() {
let matcher = SkimMatcherV2::default().ignore_case();

View file

@ -22,26 +22,43 @@ pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) ->
}
}
/// Given 2 character, check if they are equal (considering ascii case)
/// Convert the Unicode fullwidth form of an ASCII character to ASCII.
#[inline]
fn narrow_ascii_width(ch: char) -> char {
match ch {
'\u{3000}' => ' ',
'\u{FF01}'..='\u{FF5E}' => char::from_u32(ch as u32 - 0xFEE0).unwrap_or(ch),
_ => ch,
}
}
/// Given two characters, check if they are equal after folding ASCII width and,
/// when requested, case.
/// e.g. ('a', 'A', true) => false
/// e.g. ('a', 'A', false) => true
/// e.g. ('', 'a', true) => true
#[inline]
pub fn char_equal(a: char, b: char, case_sensitive: bool) -> bool {
if case_sensitive {
a == b
} else {
let a_lower = a.to_lowercase();
let mut b_lower = b.to_lowercase();
for a_n in a_lower {
let Some(b_n) = b_lower.next() else {
return false;
};
if a_n != b_n {
return false;
}
}
true
if a == b {
return true;
}
let a = narrow_ascii_width(a);
let b = narrow_ascii_width(b);
if a == b {
return true;
}
if case_sensitive {
return false;
}
if a.is_ascii() && b.is_ascii() {
return a.eq_ignore_ascii_case(&b);
}
a.to_lowercase().eq(b.to_lowercase())
}
#[derive(Debug, PartialEq)]
@ -158,11 +175,56 @@ mod tests {
assert!(!char_equal('a', 'b', false));
}
#[test]
fn char_equal_folds_fullwidth_ascii() {
assert!(char_equal('', 'a', true));
assert!(char_equal('', 'A', true));
assert!(!char_equal('', 'a', true));
assert!(char_equal('', 'a', false));
assert!(char_equal('', '1', true));
assert!(char_equal(' ', ' ', true));
}
#[test]
fn char_equal_multichar_lowercase_mismatch() {
// 'İ' (U+0130) lowercases to two chars ("i" + combining dot), so it is
// not equal to the single char 'i' — exercising the length-mismatch path.
assert!(!char_equal('İ', 'i', false));
// ...and the comparison must be symmetric. This direction used to
// return `true` because the shorter sequence was exhausted first, which
// made `cheap_matches` and `allow_match` disagree and drove the clangd
// matcher's backtracking past the start of the DP matrix.
assert!(!char_equal('i', 'İ', false));
assert!(!char_equal('I', 'İ', false));
assert!(!char_equal('İ', 'I', false));
}
#[test]
fn char_equal_is_symmetric() {
// Exhaustively check symmetry against chars with multi-char or
// otherwise unusual lowercase mappings.
let probes = ['i', 'I', 'İ', 'ı', 'ß', 'ẞ', 'ffl', 'ς', 'Σ', 'K', 'İ', 'Dž'];
for u in 0..=0x2FFFu32 {
let Some(ch) = char::from_u32(u) else { continue };
for p in probes {
for case_sensitive in [true, false] {
assert_eq!(
char_equal(ch, p, case_sensitive),
char_equal(p, ch, case_sensitive),
"char_equal is asymmetric for ({ch:?}, {p:?}, {case_sensitive})"
);
}
}
}
}
#[test]
fn char_equal_reflexive() {
for u in 0..=0x2FFFu32 {
let Some(ch) = char::from_u32(u) else { continue };
assert!(char_equal(ch, ch, true));
assert!(char_equal(ch, ch, false));
}
}
#[test]

View file

@ -265,16 +265,13 @@ impl SkimItemReader {
/// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
/// `source`, splitting on line boundaries, and sends them tagged with
/// monotonic sequence numbers into a bounded channel.
/// 2. **Dispatcher thread** (dedicated, lightweight) — drains that channel
/// and submits one pool job per chunk. The bounded channel provides
/// natural back-pressure on the I/O thread when the pool is busy.
/// 2. **Bounded dispatcher** — submits chunk jobs to the pool while a token
/// limit bounds queued and running work. It stops draining the input
/// channel when that limit is reached, which applies back-pressure to I/O.
/// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
/// field transforms, and create `DefaultSkimItem` + `Arc` per line.
/// Because these jobs share the same pool as the matcher, reader and
/// matcher compete for the same thread budget rather than over-subscribing
/// available CPU cores.
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from pool
/// jobs and emits them in sequence order so downstream index assignment
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from workers
/// and emits them in sequence order so downstream index assignment
/// and `--tac` behaviour are correct.
///
/// When `child` is `Some`, a **killer thread** is also spawned. It waits
@ -303,18 +300,27 @@ impl SkimItemReader {
// Stage 1: I/O thread.
Self::spawn_io_reader(source, tx_chunks, line_ending);
// Stage 2: dispatcher thread — bridges the bounded channel to the pool.
// Stage 2: dispatch at most a fixed number of queued or running jobs.
// A worker returns its token only after it sends the parsed result.
let max_in_flight = num_threads * 4;
let (tx_permits, rx_permits) = std::sync::mpsc::sync_channel(max_in_flight);
for _ in 0..max_in_flight {
tx_permits.send(()).expect("permit receiver is alive");
}
thread::spawn(move || {
while let Ok((seq, chunk)) = rx_chunks.recv() {
if rx_permits.recv().is_err() {
break;
}
let tx = tx_results.clone();
let return_permit = tx_permits.clone();
let opt = option.clone();
pool.spawn(move || {
let result = Self::process_chunk(seq, &chunk, &opt);
let _ = tx.send(result);
let _ = return_permit.send(());
});
}
// rx_chunks closed → all chunks dispatched; tx_results dropped here
// so the reorder thread exits once the last pool job finishes.
});
// A zero-capacity channel used as a completion signal: the reorder

View file

@ -24,12 +24,14 @@ use tokio::sync::Notify;
#[derive(Debug)]
pub struct RankBuilder {
criterion: Vec<RankCriteria>,
tac: bool,
}
impl Default for RankBuilder {
fn default() -> Self {
Self {
criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
tac: false,
}
}
}
@ -43,7 +45,13 @@ impl RankBuilder {
}
criterion.dedup();
Self { criterion }
Self { criterion, tac: false }
}
#[must_use]
pub(crate) fn tac(mut self, tac: bool) -> Self {
self.tac = tac;
self
}
/// Returns the tiebreak criteria slice.
@ -52,11 +60,40 @@ impl RankBuilder {
&self.criterion
}
/// Computes the byte offset of the first character after the last path separator
/// (`/` or `\`) in `text`. Returns `0` when no separator is present.
fn sort_key(&self, rank: &Rank) -> [i32; 6] {
let configured = rank.sort_key(&self.criterion);
let mut key = [0; 6];
key[..5].copy_from_slice(&configured);
if self.tac {
for (priority, criterion) in self.criterion.iter().take(5).enumerate() {
key[priority] = match criterion {
RankCriteria::Index => rank.index.saturating_neg(),
RankCriteria::NegIndex => rank.index,
_ => key[priority],
};
}
}
key[5] = if self.tac {
rank.index.saturating_neg()
} else {
rank.index
};
key
}
/// Computes the **character** index of the first character after the last path
/// separator (`/` or `\`) in `text`. Returns `0` when no separator is present.
///
/// This must be a char index, not a byte offset: the `PathName` tiebreak
/// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
/// here would mix units and mis-rank any path with a non-ASCII component.
fn path_name_offset(text: &str) -> i32 {
text.rfind(['/', '\\'])
.map_or(0, |pos| i32::try_from(pos).unwrap_or(i32::MAX).saturating_add(1))
text.rfind(['/', '\\']).map_or(0, |pos| {
i32::try_from(text[..pos].chars().count())
.unwrap_or(i32::MAX)
.saturating_add(1)
})
}
/// Builds a `Rank` from raw match measurements.
@ -121,8 +158,9 @@ pub struct MatchedItem {
/// Range of characters that matched the pattern
pub matched_range: Option<MatchRange>,
/// Sort key precomputed at construction time from `rank` and the tiebreak
/// criteria. Caching avoids recomputing it on every comparison during sort.
sort_key: [i32; 5],
/// criteria. The sixth slot is the tac-aware implicit index tiebreak.
/// Caching avoids recomputing it on every comparison during sort.
sort_key: [i32; 6],
}
impl std::fmt::Debug for MatchedItem {
@ -162,7 +200,7 @@ impl MatchedItem {
item,
rank,
matched_range,
sort_key: rank.sort_key(rank_builder.criteria()),
sort_key: rank_builder.sort_key(&rank),
}
}
/// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
@ -359,9 +397,7 @@ impl PartialOrd for MatchedItem {
impl Ord for MatchedItem {
fn cmp(&self, other: &Self) -> CmpOrd {
self.sort_key
.cmp(&other.sort_key)
.then_with(|| self.rank.index.cmp(&other.rank.index))
self.sort_key.cmp(&other.sort_key)
}
}

View file

@ -37,6 +37,33 @@ fn build_rank_records_offsets_and_pathname() {
assert_eq!(rank.path_name_offset, i32::try_from("src/lib/".len()).unwrap());
}
#[test]
fn path_name_offset_counts_chars_not_bytes() {
// `path_name_offset` is subtracted from `Rank::begin`, which is a char index,
// so a multi-byte directory component must not inflate it.
let rb = RankBuilder::default();
// "ééé/" is 4 chars but 7 bytes; the filename starts at char index 4.
let rank = rb.build_rank(0, 4, 5, "ééé/a");
assert_eq!(rank.path_name_offset, 4);
// With the match sitting on the filename, PathName must score it as 0 (best).
// A byte-based offset would give 7 - 4 = 3 and rank it below a plain match.
assert_eq!(rank.sort_key(&[RankCriteria::PathName])[0], 0);
}
#[test]
fn pathname_tiebreak_prefers_filename_match_with_non_ascii_dir() {
// "ééééé/a" matches in the filename (best), "a/xxxxx" matches in the dir part.
let rb = RankBuilder::new(vec![RankCriteria::PathName, RankCriteria::Index]);
let in_filename = MatchedItem::new(item("ééééé/a"), rb.build_rank(0, 6, 7, "ééééé/a"), None, &rb);
let in_dir = MatchedItem::new(item("a/xxxxx"), rb.build_rank(0, 0, 1, "a/xxxxx"), None, &rb);
assert!(
in_filename < in_dir,
"a filename match must outrank a directory match even when the directory is non-ASCII"
);
}
#[test]
fn sort_key_flips_score_sign() {
let rank = Rank {
@ -59,6 +86,57 @@ fn matched_item_ordering_prefers_higher_score() {
assert!(high < low);
}
#[test]
fn matched_item_ordering_reverses_stable_input_index_for_tac() {
let old_rank = Rank {
index: 0,
..Default::default()
};
let new_rank = Rank {
index: 1,
..Default::default()
};
let normal_builder = RankBuilder::new(vec![RankCriteria::Index]);
let old = MatchedItem::new(item("old"), old_rank, None, &normal_builder);
let new = MatchedItem::new(item("new"), new_rank, None, &normal_builder);
assert!(old < new);
let tac_builder = RankBuilder::new(vec![RankCriteria::Index]).tac(true);
let old = MatchedItem::new(item("old"), old_rank, None, &tac_builder);
let new = MatchedItem::new(item("new"), new_rank, None, &tac_builder);
assert!(new < old);
assert_eq!(new.rank.index, 1);
assert_eq!(MatchedItem::sorted_merge(vec![old], vec![new])[0].text(), "new");
let tac_builder = RankBuilder::default().tac(true);
let old = MatchedItem::new(item("old"), old_rank, None, &tac_builder);
let new = MatchedItem::new(item("new"), new_rank, None, &tac_builder);
assert!(new < old);
}
#[test]
fn sorted_merge_tac_places_newer_incremental_batch_first() {
let rank_builder = RankBuilder::default().tac(true);
let make = |text: &str, index| {
MatchedItem::new(
item(text),
Rank {
index,
..Default::default()
},
None,
&rank_builder,
)
};
let existing = vec![make("c", 2), make("b", 1), make("a", 0)];
let incoming = vec![make("e", 4), make("d", 3)];
let merged = MatchedItem::sorted_merge(existing, incoming);
let indexes: Vec<_> = merged.iter().map(|item| item.rank.index).collect();
assert_eq!(indexes, [4, 3, 2, 1, 0]);
}
#[test]
fn sorted_merge_handles_empty_inputs() {
let a = vec![matched("a", 0, 10)];

View file

@ -2,14 +2,13 @@
use crate::thread_pool::{self, ThreadPool};
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use crate::engine::normalized::NormalizedEngineFactory;
use crate::engine::split::SplitMatchEngineFactory;
use crate::item::{ItemPool, MatchedItem, RankBuilder};
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
use crate::spinlock::SpinLock;
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
/// Merges per-worker match results and writes them into `processed_items`.
@ -25,13 +24,28 @@ use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
/// flattened without sorting.
///
/// Signals `needs_render` after writing so the UI picks up the new data.
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
debug_assert!(batch_index < batch_len);
if tac {
start + batch_len - 1 - batch_index
} else {
start + batch_index
}
}
fn merge_worker_results(
worker_results: Vec<Vec<MatchedItem>>,
no_sort: bool,
processed_items: &SpinLock<Option<ProcessedItems>>,
processed_items: &Mutex<Option<ProcessedItems>>,
merge_strategy: MergeStrategy,
generation: usize,
current_generation: &AtomicUsize,
needs_render: &AtomicBool,
) {
if current_generation.load(Ordering::Acquire) != generation {
return;
}
let total_len: usize = worker_results.iter().map(Vec::len).sum();
let mut items = Vec::with_capacity(total_len);
for chunk in worker_results {
@ -46,21 +60,40 @@ fn merge_worker_results(
trace!("matcher stop, total matched: {}", items.len());
// Single lock, single write into processed_items.
let mut guard = processed_items.lock();
// Validate while holding the result lock so an old matcher cannot overwrite
// results that belong to a newer query generation.
let mut guard = processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if current_generation.load(Ordering::Acquire) != generation {
return;
}
if matches!(merge_strategy, MergeStrategy::Replace) {
*guard = Some(ProcessedItems {
items,
merge: MergeStrategy::Replace,
generation,
});
drop(guard);
needs_render.store(true, Ordering::Relaxed);
return;
}
match &mut *guard {
Some(existing) if existing.generation != generation => {
*guard = Some(ProcessedItems {
items,
merge: MergeStrategy::Replace,
generation,
});
}
Some(existing) => {
if no_sort {
existing.items.extend(items);
if matches!(merge_strategy, MergeStrategy::Prepend) {
items.append(&mut existing.items);
existing.items = items;
} else {
existing.items.extend(items);
}
} else {
// Both sides are fully sorted — one O(n+m) merge.
MatchedItem::merge_into_sorted(&mut existing.items, items);
@ -70,6 +103,7 @@ fn merge_worker_results(
*guard = Some(ProcessedItems {
items,
merge: merge_strategy,
generation,
});
}
}
@ -186,15 +220,16 @@ impl Matcher {
#[must_use]
pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
if options.regex {
let regex_factory = RegexEngineFactory::builder();
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
let regex_factory = RegexEngineFactory::builder().rank_builder(rank_builder.clone());
let factory: Rc<dyn MatchEngineFactory> = if options.normalize {
Rc::new(NormalizedEngineFactory::new(regex_factory))
} else {
Rc::new(regex_factory)
};
(factory, Arc::new(RankBuilder::default()))
(factory, rank_builder)
} else {
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()));
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
log::debug!("Creating matcher for algo {:?}", options.algorithm);
let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
.fuzzy_algorithm(options.algorithm)
@ -261,9 +296,12 @@ impl Matcher {
query: &str,
item_pool: &Arc<ItemPool>,
thread_pool: &Arc<ThreadPool>,
processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
processed_items: Arc<Mutex<Option<ProcessedItems>>>,
merge_strategy: MergeStrategy,
no_sort: bool,
tac: bool,
generation: usize,
current_generation: Arc<AtomicUsize>,
needs_render: Arc<AtomicBool>,
) -> MatcherControl {
let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
@ -336,7 +374,10 @@ impl Matcher {
if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
chunk_matched += 1;
let mut rank = match_result.rank;
let index = chunk_start + i + start;
let batch_index = chunk_start + i;
// `take()` reverses each tac batch, so recover the
// item's stable ordinal in the original input stream.
let index = input_index(tac, start, total, batch_index);
rank.index = i32::try_from(index).unwrap_or(i32::MAX);
local_matches.push(MatchedItem::new(
Arc::clone(item),
@ -383,7 +424,15 @@ impl Matcher {
return;
}
merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
merge_worker_results(
worker_results,
no_sort,
&processed_items,
merge_strategy,
generation,
&current_generation,
&needs_render,
);
},
);
stopped.store(true, Ordering::Relaxed);
@ -451,31 +500,123 @@ mod tests {
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn regex_factory_uses_configured_tiebreak() {
let options = SkimOptionsBuilder::default()
.regex(true)
.tiebreak(vec![crate::RankCriteria::Length])
.build()
.unwrap();
let (factory, rank_builder) = Matcher::create_engine_factory_with_builder(&options);
let engine = factory.create_engine("a");
let mut matches: Vec<_> = ["aaaa", "a", "aaa"]
.into_iter()
.enumerate()
.map(|(index, text)| {
let item: Arc<dyn SkimItem> = Arc::new(text.to_string());
let mut result = engine.match_item(item.as_ref()).unwrap();
result.rank.index = i32::try_from(index).unwrap();
MatchedItem::new(item, result.rank, Some(result.matched_range), &rank_builder)
})
.collect();
matches.sort();
let output: Vec<_> = matches.iter().map(|item| item.text().into_owned()).collect();
assert_eq!(output, ["a", "aaa", "aaaa"]);
}
fn merge_test_results(
worker_results: Vec<Vec<MatchedItem>>,
no_sort: bool,
processed_items: &Mutex<Option<ProcessedItems>>,
merge_strategy: MergeStrategy,
needs_render: &AtomicBool,
) {
let generation = AtomicUsize::new(0);
merge_worker_results(
worker_results,
no_sort,
processed_items,
merge_strategy,
0,
&generation,
needs_render,
);
}
#[test]
fn merge_worker_results_replace_sorts() {
let processed = SpinLock::new(None);
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
merge_worker_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
merge_test_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
assert!(needs_render.load(Ordering::Relaxed));
let guard = processed.lock();
let guard = processed.lock().unwrap();
let items = &guard.as_ref().unwrap().items;
assert_eq!(items.len(), 2);
}
#[test]
fn stale_generation_cannot_publish_results() {
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
let generation = AtomicUsize::new(2);
merge_worker_results(
vec![vec![matched("stale", 0)]],
false,
&processed,
MergeStrategy::Replace,
1,
&generation,
&needs_render,
);
assert!(processed.lock().unwrap().is_none());
assert!(!needs_render.load(Ordering::Relaxed));
}
#[test]
fn current_results_replace_pending_stale_generation() {
let processed = Mutex::new(Some(ProcessedItems {
items: vec![matched("stale", 0)],
merge: MergeStrategy::SortedMerge,
generation: 0,
}));
let needs_render = AtomicBool::new(false);
let generation = AtomicUsize::new(1);
merge_worker_results(
vec![vec![matched("current", 1)]],
false,
&processed,
MergeStrategy::SortedMerge,
1,
&generation,
&needs_render,
);
let guard = processed.lock().unwrap();
let result = guard.as_ref().unwrap();
assert_eq!(result.generation, 1);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].text(), "current");
assert!(matches!(result.merge, MergeStrategy::Replace));
}
#[test]
fn merge_worker_results_no_sort_preserves_chunk_order() {
let processed = SpinLock::new(None);
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
let workers = vec![
vec![matched("a", 0), matched("b", 1)],
vec![matched("c", 2), matched("d", 3)],
vec![matched("e", 4), matched("f", 5)],
];
merge_worker_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
merge_test_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
let guard = processed.lock();
let guard = processed.lock().unwrap();
let items = &guard.as_ref().unwrap().items;
let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
@ -483,11 +624,11 @@ mod tests {
#[test]
fn merge_worker_results_append_no_sort_extends_existing() {
let processed = SpinLock::new(None);
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
// First append establishes the existing list.
merge_worker_results(
merge_test_results(
vec![vec![matched("a", 0)]],
true,
&processed,
@ -495,7 +636,7 @@ mod tests {
&needs_render,
);
// Second append with no_sort extends the existing list in place.
merge_worker_results(
merge_test_results(
vec![vec![matched("b", 1)]],
true,
&processed,
@ -503,7 +644,49 @@ mod tests {
&needs_render,
);
let guard = processed.lock();
let guard = processed.lock().unwrap();
assert_eq!(guard.as_ref().unwrap().items.len(), 2);
}
#[test]
fn tac_input_index_spans_incremental_batches() {
let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
assert_eq!(first_batch, [2, 1, 0]);
assert_eq!(second_batch, [4, 3]);
assert_eq!(input_index(false, 3, 2, 0), 3);
assert_eq!(input_index(false, 3, 2, 1), 4);
}
#[test]
fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
merge_test_results(
vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
true,
&processed,
MergeStrategy::Prepend,
&needs_render,
);
merge_test_results(
vec![vec![matched("e", 4), matched("d", 3)]],
true,
&processed,
MergeStrategy::Prepend,
&needs_render,
);
let guard = processed.lock().unwrap();
let indexes: Vec<i32> = guard
.as_ref()
.unwrap()
.items
.iter()
.map(|item| item.rank.index)
.collect();
assert_eq!(indexes, [4, 3, 2, 1, 0]);
}
}

View file

@ -23,6 +23,17 @@ use crate::tui::{BorderType, PreviewCallback};
use crate::util::read_file_lines;
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
s.parse().map_err(|_| MIN_HEIGHT_PARSE_ERROR.to_string())
}
#[cfg(feature = "cli")]
fn parse_min_height_value(s: &str) -> Result<String, String> {
parse_min_height(s).map(|_| s.to_string())
}
#[cfg(feature = "cli")]
/// Custom value parser for delimiter that handles escape sequences
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
@ -196,7 +207,13 @@ pub struct SkimOptions {
/// See **nth** for the details
#[cfg_attr(
feature = "cli",
arg(long, default_value = "", help_heading = "Search", value_delimiter = ',')
arg(
long,
default_value = "",
help_heading = "Search",
value_delimiter = ',',
allow_hyphen_values = true,
)
)]
pub with_nth: Vec<String>,
@ -496,13 +513,21 @@ pub struct SkimOptions {
#[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
pub no_height: bool,
/// Minimum height of skim's window
/// Minimum height of skim's window as a non-negative row count
///
/// Useful when the height is set as a percentage
/// Ignored when --height is not specified
/// Must be a non-negative row count, not a percentage.
/// Useful when the height is set as a percentage.
/// Ignored when --height is not specified.
#[cfg_attr(
feature = "cli",
arg(long, default_value = "10", help_heading = "Layout", verbatim_doc_comment)
arg(
long,
default_value = "10",
help_heading = "Layout",
allow_hyphen_values = true,
value_parser = parse_min_height_value,
verbatim_doc_comment
)
)]
pub min_height: String,
@ -702,9 +727,9 @@ pub struct SkimOptions {
// --- Preview ---
/// Preview command
///
/// Execute the given command for the current line and display the result on the preview window. {} in the command
/// is the placeholder that is replaced to the single-quoted string of the current line. To transform the
/// replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
/// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
/// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
/// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
///
/// **Examples**:
///

View file

@ -2,6 +2,7 @@
//! and history initialization, which apply defaults and cross-option rules.
use super::*;
use crate::field::FieldRange;
use crate::item::RankCriteria;
use crate::tui::statusline::InfoDisplay;
@ -21,6 +22,50 @@ fn merge(
.expect("options should parse")
}
#[test]
fn min_height_accepts_a_non_negative_row_count() {
assert_eq!(super::parse_min_height("60"), Ok(60));
assert_eq!(
SkimOptionsBuilder::default()
.min_height("60")
.build()
.unwrap()
.min_height,
"60"
);
}
#[test]
fn min_height_rejects_non_integers() {
for value in ["-1", "30%", "many"] {
assert_eq!(
super::parse_min_height(value),
Err("min-height needs to be a non-negative integer".to_string())
);
}
}
#[cfg(feature = "cli")]
#[test]
fn cli_min_height_uses_custom_error() {
for value in ["-1", "30%"] {
let error = SkimOptions::merge_args_and_parse(
"sk".to_string(),
None,
None,
["--min-height".to_string(), value.to_string()],
None,
)
.expect_err("invalid min-height must fail");
assert!(
error
.to_string()
.contains("min-height needs to be a non-negative integer")
);
}
}
#[test]
fn merge_uses_skim_default_command_when_no_cmd_flag() {
// SKIM_DEFAULT_COMMAND fills `cmd` when neither --cmd nor a pipe is given.
@ -287,3 +332,38 @@ fn build_history_file_adds_history_keybindings() {
let _ = std::fs::remove_file(&qpath);
}
/// Helper: parse real CLI args with no env influence.
fn parse_args(args: &[&str]) -> Result<SkimOptions, clap::Error> {
SkimOptions::merge_args_and_parse(
"sk".to_string(),
None,
None,
args.iter().map(|s| (*s).to_string()),
None,
)
}
#[test]
fn negative_field_indices_parse_for_every_nth_flag() {
// All three flags document the same `nth` syntax, which includes `-1` for the
// last field; a space-separated negative value must not be read as a flag.
for flag in ["--nth", "--with-nth", "--hide-nth"] {
let opts = parse_args(&[flag, "-1"]).unwrap_or_else(|e| panic!("{flag} -1 failed to parse: {e}"));
let got = match flag {
"--nth" => &opts.nth,
"--with-nth" => &opts.with_nth,
_ => &opts.hide_nth,
};
assert_eq!(got, &vec!["-1".to_string()], "{flag}");
assert_eq!(
FieldRange::from_str(&got[0]),
Some(FieldRange::Single(-1)),
"{flag} should resolve to the last field"
);
}
// ...and a negative index inside a comma-separated list.
let opts = parse_args(&["--with-nth", "2,-1"]).expect("--with-nth 2,-1 should parse");
assert_eq!(opts.with_nth, vec!["2".to_string(), "-1".to_string()]);
}

View file

@ -10,7 +10,9 @@ use crate::{SkimItem, SkimItemReceiver};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
/// Trait for collecting items from command output
pub trait CommandCollector {
@ -39,6 +41,7 @@ pub struct ReaderControl {
tx_interrupt: Sender<i32>,
tx_interrupt_cmd: Option<Sender<i32>>,
components_to_stop: Arc<AtomicUsize>,
collector_handle: Option<JoinHandle<()>>,
items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
}
@ -52,7 +55,14 @@ impl ReaderControl {
let _ = self.tx_interrupt_cmd.clone().map(|tx| tx.send(1));
let _ = self.tx_interrupt.send(1);
while self.components_to_stop.load(Ordering::SeqCst) != 0 {}
if let Some(handle) = self.collector_handle.take() {
let _ = handle.join();
}
// Command collectors can own additional components outside the reader's
// join handle. Wait without consuming a CPU while they process the signal.
while self.components_to_stop.load(Ordering::Acquire) != 0 {
std::thread::sleep(Duration::from_millis(1));
}
}
/// Takes all items collected so far
@ -123,12 +133,14 @@ impl Reader {
);
let components_to_stop_clone = components_to_stop.clone();
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
let (tx_interrupt, collector_handle) =
collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
ReaderControl {
tx_interrupt,
tx_interrupt_cmd,
components_to_stop,
collector_handle: Some(collector_handle),
items,
}
}
@ -149,7 +161,7 @@ impl Reader {
);
let components_to_stop_clone = components_to_stop.clone();
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| {
let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| {
item_pool.append(items);
});
debug!("collect: started ({components_to_stop:?} components)");
@ -158,6 +170,7 @@ impl Reader {
tx_interrupt,
tx_interrupt_cmd,
components_to_stop,
collector_handle: Some(collector_handle),
items,
}
}
@ -172,18 +185,19 @@ impl Default for Reader {
}
}
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
fn collect_items<F>(
components_to_stop: Arc<AtomicUsize>,
rx_item: SkimItemReceiver,
callback: F,
) -> (Sender<i32>, JoinHandle<()>)
where
F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
{
let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
let started = Arc::new(AtomicBool::new(false));
let started_clone = started.clone();
std::thread::spawn(move || {
components_to_stop.fetch_add(1, Ordering::AcqRel);
let handle = std::thread::spawn(move || {
debug!("collect_item start");
components_to_stop.fetch_add(1, Ordering::SeqCst);
started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
loop {
if let Ok(Some(msg)) = rx_interrupt.try_recv() {
@ -205,15 +219,11 @@ where
}
}
components_to_stop.fetch_sub(1, Ordering::SeqCst);
components_to_stop.fetch_sub(1, Ordering::AcqRel);
debug!("collect_item stop");
});
while !started.load(Ordering::SeqCst) {
// busy waiting for the thread to start. (components_to_stop is added)
}
tx_interrupt
(tx_interrupt, handle)
}
#[cfg(test)]

View file

@ -14,6 +14,8 @@ use tokio::task::block_in_place;
use crate::binds::SkimEvent;
use crate::reader::{Reader, ReaderControl};
use crate::tui::actions::Action;
#[cfg(feature = "image")]
use crate::tui::util::detect_image_picker;
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
@ -138,6 +140,8 @@ impl Skim {
if self.app.options.no_mouse {
tui.disable_mouse();
}
let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)?;
tui.min_height(min_height)?;
self.tui = Some(tui);
Ok(())
}
@ -400,7 +404,7 @@ where
if !tui.is_fullscreen {
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
}
let picker = Picker::from_query_stdio().unwrap_or_else(|err| {
let picker = detect_image_picker().unwrap_or_else(|err| {
warn!("failed to query terminal image protocol: {err:?}");
Picker::halfblocks()
});
@ -442,6 +446,18 @@ where
if app.options.filter.is_some() {
trace!("filter mode: waiting for all items to be processed");
loop {
// `--min-query-length` short-circuits `restart_matcher`, so the pool would
// never be drained and this loop would spin forever. There is nothing to
// match in that case: stop as soon as the reader is done.
if app.query_below_min_length() {
if reader_control.is_done() {
debug!("filter mode: query shorter than --min-query-length, no results");
app.item_list.items.clear();
return false;
}
std::thread::sleep(Duration::from_millis(1));
continue;
}
let matcher_stopped = app.matcher_control.stopped();
let reader_done = reader_control.is_done();
if matcher_stopped && reader_done && app.item_pool.num_not_taken() == 0 {
@ -454,6 +470,7 @@ where
.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.unwrap_or_default()
.items
@ -505,7 +522,14 @@ where
app.matcher_control.get_num_matched()
);
if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
app.item_list.items = app
.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.unwrap_or_default()
.items;
debug!("early exit, result: {:?}", app.results());
return false;
}

View file

@ -66,6 +66,42 @@ fn should_enter_is_false_in_filter_mode() {
assert_eq!(skim.app().item_list.items.len(), 3);
}
#[test]
fn filter_mode_terminates_when_query_is_below_min_query_length() {
// `--min-query-length` makes `restart_matcher` a no-op, so the filter loop must
// not wait for the item pool to drain — it used to spin forever here. Run it on a
// worker thread so the regression surfaces as a failed assertion, not a hung test.
let (done_tx, done_rx) = crate::prelude::unbounded::<(bool, usize)>();
let worker = std::thread::spawn(move || {
let mut options = SkimOptions::default();
options.filter = Some("a".to_string());
options.min_query_length = Some(3);
let options = options.build();
let mut skim = started_skim_with(options, &["a", "b", "c"]);
let entered = skim.should_enter();
let _ = done_tx.send((entered, skim.app().item_list.items.len()));
});
let (entered, matched) = done_rx
.recv_timeout(Duration::from_secs(10))
.expect("filter mode did not terminate with a query below --min-query-length");
worker.join().unwrap();
assert!(!entered);
// The query is too short, so nothing is reported as matched.
assert_eq!(matched, 0);
}
#[test]
fn filter_mode_matches_when_query_meets_min_query_length() {
let mut options = SkimOptions::default();
options.filter = Some("abc".to_string());
options.min_query_length = Some(3);
let options = options.build();
let mut skim = started_skim_with(options, &["abc", "xyz"]);
assert!(!skim.should_enter());
assert_eq!(skim.app().item_list.items.len(), 1);
}
#[test]
fn should_enter_is_false_for_select_1_single_match() {
let mut options = SkimOptions::default();

View file

@ -26,9 +26,9 @@ pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
}
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
/// Creates a new guard for the given lock
pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
Self { __lock: pool }
/// Creates a guard after its lock has been acquired.
fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
Self { __lock: lock }
}
}

View file

@ -582,7 +582,7 @@ impl App {
u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
};
self.preview.scroll_y = v_scroll;
self.preview.scroll_y = usize::from(v_scroll);
self.preview.scroll_down(v_offset);
let h_scroll = match preview_position.h_scroll {
@ -599,7 +599,7 @@ impl App {
u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
};
self.preview.scroll_x = h_scroll.saturating_add(h_offset);
self.preview.scroll_x = usize::from(h_scroll).saturating_add(usize::from(h_offset));
}
ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(t, preview_position) => self
.preview
@ -1348,7 +1348,7 @@ impl App {
pub fn results(&mut self) -> Vec<MatchedItem> {
if self.options.filter.is_some() {
// In filter mode, drain items to avoid cloning
self.item_list.items.drain(..).collect()
std::mem::take(&mut self.item_list.items)
} else if self.options.multi && !self.item_list.selection.is_empty() {
self.item_list.selection.clone().into_iter().collect()
} else if let Some(sel) = self.item_list.selected() {
@ -1358,6 +1358,18 @@ impl App {
}
}
/// Whether the current query is shorter than `--min-query-length`, meaning no
/// results should be produced yet.
///
/// Always false when `--min-query-length` is unset, or under `--disabled`, where
/// the input is not used as a query.
#[must_use]
pub fn query_below_min_length(&self) -> bool {
self.options
.min_query_length
.is_some_and(|min| !self.options.disabled && self.input.value.chars().count() < min)
}
/// Restart the matcher to process items in the item pool.
///
/// If `force` is true, the matcher will be restarted even if it's currently running.
@ -1366,19 +1378,19 @@ impl App {
pub fn restart_matcher(&mut self, force: bool) {
use crate::tui::item_list::MergeStrategy;
// Check if query meets minimum length requirement
if let Some(min_length) = self.options.min_query_length
&& !self.options.disabled
{
let query_to_check = &self.input.value;
if query_to_check.chars().count() < min_length {
// Query is too short, clear items and don't run matcher
self.matcher_control.kill();
self.item_list.items.clear();
self.item_list.current = 0;
self.item_list.offset = 0;
return;
}
if self.query_below_min_length() {
// Query is too short, clear items and don't run matcher
self.matcher_control.kill();
self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel);
self.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.item_list.items.clear();
self.item_list.current = 0;
self.item_list.offset = 0;
return;
}
let matcher_stopped = self.matcher_control.stopped();
@ -1407,8 +1419,16 @@ impl App {
self.item_pool.reset();
}
let generation = if force {
self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel) + 1
} else {
self.item_list.matcher_generation.load(Ordering::Acquire)
};
let merge_strategy = if force {
MergeStrategy::Replace
} else if no_sort && self.options.tac {
MergeStrategy::Prepend
} else if no_sort {
MergeStrategy::Append
} else {
@ -1422,6 +1442,9 @@ impl App {
self.item_list.processed_items.clone(),
merge_strategy,
no_sort,
self.options.tac,
generation,
self.item_list.matcher_generation.clone(),
self.needs_render.clone(),
);
// A new search is in flight; arm the `result`/`zero`/`one` events to

View file

@ -42,6 +42,8 @@ where
pub cancellation_token: CancellationToken,
/// Whether running in fullscreen mode
pub is_fullscreen: bool,
/// The terminal's rect (drawing) area, set if the layout is inline
rect: Option<Rect>,
enable_mouse: bool,
}
@ -87,6 +89,7 @@ where
Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
};
let rect: Option<Rect>;
let viewport = if let Some(mut height) = lines {
// Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
let cursor_pos = cursor_pos_from_tty()?;
@ -97,13 +100,15 @@ where
crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
y = y.saturating_sub(to_scroll);
}
Viewport::Fixed(Rect::new(
rect = Some(Rect::new(
0,
y,
backend.size().expect("Failed to get terminal width").width - 1,
height,
))
));
Viewport::Fixed(rect.unwrap())
} else {
rect = None;
Viewport::Fullscreen
};
@ -111,6 +116,7 @@ where
Ok(Self {
terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })?,
task: None,
rect,
event_rx: event_channel.1,
event_tx: event_channel.0,
tick_rate: f64::from(TICK_RATE),
@ -381,6 +387,46 @@ where
self.start();
restore_result
}
/// Set the minimum height of an inline viewport.
///
/// Scrolls the terminal when there are not enough rows below the viewport's
/// current origin.
///
/// # Errors
///
/// Returns an error if the terminal size cannot be read, the terminal cannot
/// be scrolled, or the viewport cannot be resized.
pub fn min_height(&mut self, min_height: u16) -> Result<()> {
if self.is_fullscreen {
return Ok(());
}
let Some(current_rect) = self.rect else {
return Ok(());
};
let terminal_height = self.backend().size()?.height;
let (rect, to_scroll) = rect_with_min_height(current_rect, min_height, terminal_height);
if rect == current_rect {
return Ok(());
}
if to_scroll > 0 {
crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
}
debug!("min_height: resizing TUI to {rect:?}");
self.resize(rect)?;
self.rect = Some(rect);
Ok(())
}
}
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
rect.height = rect.height.max(min_height).min(terminal_height);
let lowest_origin = terminal_height.saturating_sub(rect.height);
let to_scroll = rect.y.saturating_sub(lowest_origin);
rect.y = rect.y.saturating_sub(to_scroll);
(rect, to_scroll)
}
impl<B: Backend> Deref for Tui<B>
@ -476,6 +522,14 @@ mod tests {
.expect("failed to build test TUI")
}
fn inline_tui(rect: Rect) -> Tui<TestBackend> {
let mut tui = fullscreen_tui();
tui.is_fullscreen = false;
tui.rect = Some(rect);
tui.resize(rect).expect("failed to set initial viewport");
tui
}
#[test]
fn new_with_full_height_is_fullscreen() {
let tui = fullscreen_tui();
@ -507,4 +561,50 @@ mod tests {
assert_eq!(area.width, 80);
assert_eq!(area.height, 24);
}
#[test]
fn min_height_resizes_and_scrolls_inline_terminal() {
let mut tui = inline_tui(Rect::new(0, 20, 79, 4));
tui.min_height(10).expect("failed to apply minimum height");
assert_eq!(tui.get_frame().area(), Rect::new(0, 14, 79, 10));
assert_eq!(tui.rect, Some(Rect::new(0, 14, 79, 10)));
}
#[test]
fn min_height_keeps_origin_when_rows_are_available() {
let rect = Rect::new(0, 5, 79, 4);
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
assert_eq!(rect, Rect::new(0, 5, 79, 10));
assert_eq!(to_scroll, 0);
}
#[test]
fn min_height_scrolls_to_make_room() {
let rect = Rect::new(0, 20, 79, 4);
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
assert_eq!(rect, Rect::new(0, 14, 79, 10));
assert_eq!(to_scroll, 6);
}
#[test]
fn min_height_is_limited_to_terminal_height() {
let rect = Rect::new(0, 20, 79, 4);
let (rect, to_scroll) = rect_with_min_height(rect, 30, 24);
assert_eq!(rect, Rect::new(0, 0, 79, 24));
assert_eq!(to_scroll, 20);
}
#[test]
fn existing_height_is_limited_to_terminal_height() {
let rect = Rect::new(0, 0, 79, 30);
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
assert_eq!(rect, Rect::new(0, 0, 79, 24));
assert_eq!(to_scroll, 0);
}
}

View file

@ -165,12 +165,9 @@ impl Input {
}
pub fn insert_str(&mut self, s: &str) {
self.value.insert_str(self.cursor_pos as usize, s);
self.move_cursor(
s.chars()
.count()
.try_into()
.expect("Failed to fit inserted str len into an i32"),
);
// `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
// inserted byte length, not the char count.
self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
}
fn nchars(&self) -> usize {
self.value.chars().count()

View file

@ -236,3 +236,36 @@ fn input_render_writes_prompt_and_value() {
}
assert!(text.contains("hello"));
}
#[test]
fn insert_str_leaves_cursor_at_end_for_multibyte() {
// `cursor_pos` is a byte offset, so advancing it by char count left the cursor
// inside the inserted text whenever a character was wider than one byte.
let mut input = Input::default();
input.insert_str("中文");
assert_eq!(input.cursor_pos as usize, "中文".len());
}
#[test]
fn consecutive_insert_str_preserves_order_for_multibyte() {
// Bracketed paste and IMEs deliver whole strings, so a wrong cursor position
// made the next chunk land in the middle of the previous one.
let mut input = Input::default();
input.insert_str("中文");
input.insert_str("测试");
assert_eq!(input.value, "中文测试");
}
#[test]
fn insert_str_matches_repeated_insert_for_multibyte() {
let mut by_str = Input::default();
by_str.insert_str("中文");
let mut by_char = Input::default();
for c in "中文".chars() {
by_char.insert(c);
}
assert_eq!(by_str.value, by_char.value);
assert_eq!(by_str.cursor_pos, by_char.cursor_pos);
}

View file

@ -1,5 +1,6 @@
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use indexmap::IndexSet;
use ratatui::widgets::{
@ -10,7 +11,6 @@ use regex::Regex;
use crate::item::MatchedItem;
use crate::options::feature_flag;
use crate::spinlock::SpinLock;
use crate::theme::ColorTheme;
use crate::tui::BorderType;
use crate::tui::item_renderer::ItemRenderer;
@ -28,12 +28,15 @@ pub(crate) enum MergeStrategy {
SortedMerge,
/// Append to existing list without sorting (for --no-sort)
Append,
/// Prepend to existing list without sorting (for --tac --no-sort)
Prepend,
}
/// Processed items ready for rendering
pub(crate) struct ProcessedItems {
pub(crate) items: Vec<MatchedItem>,
pub(crate) merge: MergeStrategy,
pub(crate) generation: usize,
}
impl Default for ProcessedItems {
@ -41,6 +44,7 @@ impl Default for ProcessedItems {
Self {
items: Vec::new(),
merge: MergeStrategy::Replace,
generation: 0,
}
}
}
@ -50,7 +54,8 @@ impl Default for ProcessedItems {
pub struct ItemList {
pub(crate) items: Vec<MatchedItem>,
pub(crate) selection: IndexSet<MatchedItem>,
pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
pub(crate) processed_items: Arc<Mutex<Option<ProcessedItems>>>,
pub(crate) matcher_generation: Arc<AtomicUsize>,
pub(crate) direction: ListDirection,
pub(crate) offset: usize,
/// How many leading sub-lines of items[offset] have been scrolled off the top.
@ -125,6 +130,27 @@ impl ItemList {
self.showing_stale_items = false;
}
/// Prepends a batch while preserving either the head-following behavior or
/// the item currently focused by a user who has moved away from the head.
fn prepend(&mut self, mut items: Vec<MatchedItem>) {
if items.is_empty() {
return;
}
let added = items.len();
let follows_head = self.current == 0;
items.append(&mut self.items);
self.items = items;
if follows_head {
self.offset = 0;
self.sub_offset = 0;
} else {
self.current = self.current.saturating_add(added);
self.offset = self.offset.saturating_add(added);
}
}
/// Toggles the selection state of the item at the given index
pub fn toggle_at(&mut self, index: usize) {
if self.items.is_empty() {
@ -397,7 +423,8 @@ impl SkimWidget for ItemList {
(None, 0)
};
let processed_items = Arc::new(SpinLock::new(None));
let processed_items = Arc::new(Mutex::new(None));
let matcher_generation = Arc::new(AtomicUsize::new(0));
let interactive = options.interactive;
let no_clear_if_empty = options.no_clear_if_empty;
@ -406,6 +433,7 @@ impl SkimWidget for ItemList {
// Spawn background processing thread with the appropriate configuration
Self {
processed_items,
matcher_generation,
reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
direction: match options.layout {
TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
@ -485,8 +513,17 @@ impl SkimWidget for ItemList {
}
let initial_current = this.selected();
// Check for pre-processed items from background thread (non-blocking)
let items_updated = if let Some(processed) = this.processed_items.lock().take() {
// Check for pre-processed items from background thread (non-blocking).
// Bind the result separately so the lock guard is dropped before a merge
// mutates the item list.
let processed = this
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let current_generation = this.matcher_generation.load(Ordering::Acquire);
let processed = processed.filter(|result| result.generation == current_generation);
let items_updated = if let Some(processed) = processed {
debug!("Render: Got {} processed items", processed.items.len());
// Check if items are empty or blank for no_clear_if_empty handling
@ -513,6 +550,9 @@ impl SkimWidget for ItemList {
MergeStrategy::Append => {
this.items.extend(processed.items);
}
MergeStrategy::Prepend => {
this.prepend(processed.items);
}
}
this.showing_stale_items = false;

View file

@ -240,7 +240,11 @@ fn render_list(il: &mut ItemList, w: u16, h: u16) {
}
fn set_processed(il: &ItemList, items: Vec<MatchedItem>, merge: MergeStrategy) {
*il.processed_items.lock() = Some(ProcessedItems { items, merge });
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
items,
merge,
generation: il.matcher_generation.load(std::sync::atomic::Ordering::Acquire),
});
}
#[test]
@ -253,6 +257,21 @@ fn render_applies_replace_strategy() {
assert_eq!(il.items[0].text(), "new");
}
#[test]
fn render_discards_results_from_stale_generation() {
let mut il = list(2);
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
items: vec![matched("stale", 0)],
merge: MergeStrategy::Replace,
generation: 0,
});
il.matcher_generation.store(1, std::sync::atomic::Ordering::Release);
render_list(&mut il, 20, 5);
assert_eq!(il.items.len(), 2);
assert!(il.items.iter().all(|item| item.text() != "stale"));
}
#[test]
fn render_applies_append_strategy() {
let mut il = list(2);
@ -273,6 +292,43 @@ fn render_applies_sorted_merge_strategy() {
assert_eq!(il.items.len(), 2);
}
#[test]
fn render_prepends_tac_batch_and_follows_head() {
let mut il = ItemList::default();
let mut base = vec![matched("c", 2), matched("b", 1), matched("a", 0)];
il.append(&mut base);
set_processed(&il, vec![matched("e", 4), matched("d", 3)], MergeStrategy::Prepend);
render_list(&mut il, 20, 5);
let texts: Vec<_> = il.items.iter().map(|item| item.item.text().into_owned()).collect();
assert_eq!(texts, ["e", "d", "c", "b", "a"]);
assert_eq!(il.current, 0);
assert_eq!(
il.selected().as_ref().map(|item| item.item.text().into_owned()),
Some("e".into())
);
}
#[test]
fn render_prepend_preserves_focus_away_from_head() {
let mut il = ItemList::default();
let mut base = vec![matched("c", 2), matched("b", 1), matched("a", 0)];
il.append(&mut base);
il.current = 1;
il.offset = 1;
set_processed(&il, vec![matched("e", 4), matched("d", 3)], MergeStrategy::Prepend);
render_list(&mut il, 20, 5);
assert_eq!(il.current, 3);
assert_eq!(il.offset, 3);
assert_eq!(
il.selected().as_ref().map(|item| item.item.text().into_owned()),
Some("b".into())
);
}
#[test]
fn render_empty_list_does_not_panic() {
let mut il = ItemList::default();

View file

@ -15,9 +15,11 @@ use tui_term::widget::PseudoTerminal;
use std::env;
use std::io::Read;
use std::sync::{Arc, RwLock, mpsc};
use std::process::{Child, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock, mpsc};
use std::thread::JoinHandle;
use std::time::Instant;
use std::time::{Duration, Instant};
use super::statusline::spinner_char;
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
@ -31,6 +33,79 @@ use crate::{SkimItem, SkimOptions};
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
const VT_SCROLLBACK: usize = 100_000;
type PlainChild = Arc<Mutex<Option<Child>>>;
fn read_bounded(mut reader: impl Read) -> Vec<u8> {
read_bounded_with_updates(&mut reader, |_| {})
}
fn read_bounded_with_updates(mut reader: impl Read, mut update: impl FnMut(&[u8])) -> Vec<u8> {
const UPDATE_INTERVAL: Duration = Duration::from_millis(16);
let mut output = Vec::with_capacity(PREVIEW_MAX_BYTES);
let mut buffer = [0; 8192];
let mut last_update = None;
let mut published_len = 0;
loop {
match reader.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
let retained = PREVIEW_MAX_BYTES.saturating_sub(output.len()).min(read);
output.extend_from_slice(&buffer[..retained]);
let update_due = last_update.is_none_or(|last: Instant| last.elapsed() >= UPDATE_INTERVAL);
if retained > 0 && (update_due || output.len() == PREVIEW_MAX_BYTES) {
update(&output);
published_len = output.len();
last_update = Some(Instant::now());
}
}
}
}
if output.len() != published_len {
update(&output);
}
output
}
fn update_plain_content(content: &RwLock<PreviewContent>, cancelled: &AtomicBool, output: &[u8]) {
let Ok(text) = output.to_vec().into_text() else {
return;
};
if let Ok(mut content) = content.write()
&& !cancelled.load(Ordering::Acquire)
{
*content = PreviewContent::Text(text);
}
}
fn terminate_plain_child(child: &PlainChild) {
let Ok(mut guard) = child.lock() else {
return;
};
let Some(child) = guard.as_mut() else {
return;
};
#[cfg(unix)]
if let Ok(process_group) = i32::try_from(child.id()) {
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
let _ = killpg(Pid::from_raw(process_group), Signal::SIGKILL);
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &child.id().to_string(), "/T", "/F"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let _ = child.kill();
}
/// Preview content options
pub(crate) enum PreviewContent {
@ -81,11 +156,13 @@ pub struct Preview {
pub cmd: String,
pub rows: u16,
pub cols: u16,
pub scroll_y: u16,
pub scroll_x: u16,
pub scroll_y: usize,
pub scroll_x: usize,
pub thread_handle: Option<JoinHandle<()>>,
/// Channel to signal thread interruption
interrupt_tx: Option<mpsc::Sender<()>>,
plain_child: Option<PlainChild>,
plain_cancelled: Option<Arc<AtomicBool>>,
pub theme: Arc<ColorTheme>,
/// Border type
pub border: BorderType,
@ -97,7 +174,7 @@ pub struct Preview {
image: bool,
#[cfg(feature = "image")]
image_picker: Option<Picker>,
pub total_lines: u16,
pub total_lines: usize,
loading: bool,
spinner_start: Instant,
}
@ -150,18 +227,12 @@ impl Preview {
}
/// Convert a Size value to an actual offset based on preview dimensions
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 {
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> usize {
let dimension = if is_vertical { self.rows } else { self.cols };
match size {
super::Size::Fixed(n) => n,
super::Size::Percent(p) => {
let dimension = if is_vertical { self.rows } else { self.cols };
// Result is at most dimension (a u16), so truncation cannot occur.
u16::try_from(u32::from(dimension) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
super::Size::Neg(n) => {
let dimension = if is_vertical { self.rows } else { self.cols };
dimension.saturating_sub(n)
}
super::Size::Fixed(n) => usize::from(n),
super::Size::Percent(p) => usize::from(dimension) * usize::from(p) / 100,
super::Size::Neg(n) => usize::from(dimension.saturating_sub(n)),
}
}
@ -226,7 +297,7 @@ impl Preview {
let Ok(mut content) = self.content.write() else {
return Err(eyre::eyre!("Failed to acquire content for writing"));
};
self.total_lines = text.lines.len().try_into().unwrap();
self.total_lines = text.lines.len();
*content = PreviewContent::Text(text);
self.scroll_y = 0;
self.scroll_x = 0;
@ -256,7 +327,7 @@ impl Preview {
}
pub fn scroll_up(&mut self, lines: u16) {
self.scroll_y = self.scroll_y.saturating_sub(lines);
self.scroll_y = self.scroll_y.saturating_sub(usize::from(lines));
}
pub fn scroll_down(&mut self, lines: u16) {
@ -265,26 +336,26 @@ impl Preview {
self.total_lines, self.rows
);
if self.total_lines > 0 {
self.scroll_y = self
.scroll_y
.saturating_add(lines)
.min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines)).min(
self.total_lines
.saturating_sub(usize::from(self.rows.saturating_sub(1))),
);
} else {
// We might not have the actual total_lines value
self.scroll_y = self.scroll_y.saturating_add(lines);
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines));
}
}
pub fn scroll_left(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
self.scroll_x = self.scroll_x.saturating_sub(usize::from(cols));
}
pub fn scroll_right(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_add(cols);
self.scroll_x = self.scroll_x.saturating_add(usize::from(cols));
}
pub fn set_offset(&mut self, offset: u16) {
self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
self.scroll_y = usize::from(offset.saturating_sub(1)); // -1 because line numbers are 1-indexed
}
pub fn page_up(&mut self) {
@ -298,10 +369,19 @@ impl Preview {
}
/// Kill the preview child process and interrupt the reader thread.
pub fn kill(&mut self) {
if let Some(cancelled) = self.plain_cancelled.take() {
cancelled.store(true, Ordering::Release);
}
if let Some(tx) = self.interrupt_tx.take() {
let _ = tx.send(());
}
if let Some(child) = self.plain_child.take() {
trace!("killing plain preview child process group");
terminate_plain_child(&child);
}
if let Some(mut child) = self.pty_child.take() {
trace!("killing pty child process");
match child.try_wait() {
@ -490,43 +570,102 @@ impl Preview {
shell_cmd
.env("ROWS", self.rows.to_string())
.env("COLUMNS", self.cols.to_string())
.env("PAGER", "");
.env("PAGER", "")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Ok(cwd) = env::current_dir() {
shell_cmd.current_dir(cwd);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
shell_cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
shell_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let (interrupt_tx, interrupt_rx) = mpsc::channel();
self.interrupt_tx = Some(interrupt_tx);
let cancelled = Arc::new(AtomicBool::new(false));
self.plain_cancelled = Some(cancelled.clone());
let mut child = match shell_cmd.spawn() {
Ok(child) => child,
Err(error) => {
log::info!("Shell cmd in error: {error:?}");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
return Ok(());
}
};
let stdout = child.stdout.take().expect("stdout was configured as piped");
let stderr = child.stderr.take().expect("stderr was configured as piped");
let child = Arc::new(Mutex::new(Some(child)));
self.plain_child = Some(child.clone());
self.thread_handle = Some(std::thread::spawn(move || {
if interrupt_rx.try_recv().is_ok() {
return;
}
let streaming_content = content.clone();
let streaming_cancelled = cancelled.clone();
let stdout_reader = std::thread::spawn(move || {
read_bounded_with_updates(stdout, |output| {
update_plain_content(&streaming_content, &streaming_cancelled, output);
})
});
let stderr_reader = std::thread::spawn(move || read_bounded(stderr));
let try_out = shell_cmd.output();
if try_out.is_err() {
log::info!("Shell cmd in error: {try_out:?}");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
return;
}
let mut out = try_out.unwrap();
if interrupt_rx.try_recv().is_ok() {
return;
}
if let Ok(mut c) = content.write() {
if out.status.success() {
out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
*c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
} else {
*c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
let status = loop {
match interrupt_rx.recv_timeout(Duration::from_millis(10)) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
terminate_plain_child(&child);
break None;
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
let wait_result = match child.lock() {
Ok(mut guard) => guard.as_mut().map(Child::try_wait),
Err(_) => break None,
};
match wait_result {
Some(Ok(Some(status))) => break Some(status),
Some(Ok(None)) => {}
Some(Err(error)) => {
log::info!("Failed to wait for preview command: {error:?}");
break None;
}
None => break None,
}
};
// A shell can exit while a background descendant still owns the pipes.
// Terminate the whole process group before joining the drain threads.
terminate_plain_child(&child);
let stdout = stdout_reader.join().unwrap_or_default();
let stderr = stderr_reader.join().unwrap_or_default();
if let Ok(mut guard) = child.lock()
&& let Some(mut child) = guard.take()
&& status.is_none()
{
let _ = child.wait();
}
trace!("sending ready ping");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
let Some(status) = status else {
return;
};
if let Ok(mut c) = content.write()
&& !cancelled.load(Ordering::Acquire)
{
let output = if status.success() { stdout } else { stderr };
*c = PreviewContent::Text(output.into_text().unwrap_or_default());
}
if !cancelled.load(Ordering::Acquire) {
trace!("sending ready ping");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
}
}));
}
Ok(())
@ -538,12 +677,14 @@ impl Preview {
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
text: &Text,
) -> u16 {
) -> usize {
// Calculate total lines in content
let total_lines: u16 = text.lines.len().try_into().unwrap();
let total_lines = text.lines.len();
// Create paragraph with optional block
let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
// Ratatui terminal coordinates are u16. Saturate previews that exceed that range.
let scroll_y = u16::try_from(self.scroll_y).unwrap_or(u16::MAX);
let scroll_x = u16::try_from(self.scroll_x).unwrap_or(u16::MAX);
let mut paragraph = Paragraph::new(text.clone()).scroll((scroll_y, scroll_x));
// Enable wrapping if wrap is true
if self.wrap {
@ -552,7 +693,7 @@ impl Preview {
// Add scroll position indicator at top-right if scrolled
if self.scroll_y > 0 && total_lines > 0 {
let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
let current_line = self.scroll_y.saturating_add(1); // Display line numbers are 1-indexed.
let title = format!("{current_line}/{total_lines}");
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
@ -569,23 +710,21 @@ impl Preview {
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
parser: &std::sync::RwLock<tui_term::vt100::Parser>,
) -> u16 {
let mut total_lines = 0u16;
) -> usize {
let mut total_lines = 0usize;
// For terminal content, manipulate scrollback to implement scrolling
if let Ok(mut parser_guard) = parser.try_write() {
let scrollback_len = parser_guard.screen().scrollback();
// Reset scrollback to its full size first
parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
// If the scrollback is not empty, we seem to be off by one
total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
.try_into()
.unwrap();
total_lines = scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count();
if self.scroll_y > 0 {
trace!("scrolling in vt buffer: {}/{}", self.scroll_y, total_lines);
// Reduce scrollback by scroll_y to show earlier content
parser_guard
.screen_mut()
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y.into()));
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y));
}
}
@ -674,6 +813,8 @@ impl SkimWidget for Preview {
scroll_x: 0,
thread_handle: None,
interrupt_tx: None,
plain_child: None,
plain_cancelled: None,
pty: None,
pty_child: None,
#[cfg(feature = "image")]

View file

@ -5,7 +5,7 @@ use ratatui::layout::Size;
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
use super::Preview;
use super::{PREVIEW_MAX_BYTES, Preview, PreviewContent, read_bounded, update_plain_content};
#[cfg(feature = "image")]
fn image(width: u32, height: u32) -> DynamicImage {
@ -60,6 +60,130 @@ fn content_loads_text_and_resets_scroll() {
assert!(!p.is_loading());
}
#[test]
fn large_text_content_does_not_overflow_line_count() {
let input = "x\n".repeat(70_000);
let mut preview = Preview::default();
preview.content(input.as_bytes()).unwrap();
assert_eq!(preview.total_lines, 70_000);
let content = preview.content.read().unwrap();
let PreviewContent::Text(text) = &*content else {
panic!("expected text preview");
};
let area = ratatui::layout::Rect::new(0, 0, 20, 5);
let mut buffer = ratatui::buffer::Buffer::empty(area);
assert_eq!(
preview.render_text(ratatui::widgets::Block::new(), area, &mut buffer, text),
70_000
);
}
#[test]
fn bounded_reader_discards_output_after_limit() {
let input = vec![b'x'; PREVIEW_MAX_BYTES + 4096];
let output = read_bounded(std::io::Cursor::new(input));
assert_eq!(output.len(), PREVIEW_MAX_BYTES);
}
fn preview_contains(preview: &Preview, expected: &str) -> bool {
preview.content.read().is_ok_and(|content| match &*content {
PreviewContent::Text(text) => text
.lines
.iter()
.any(|line| line.spans.iter().any(|span| span.content.as_ref().contains(expected))),
_ => false,
})
}
#[cfg(unix)]
#[test]
fn plain_preview_streams_before_command_exits() {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
let mut preview = Preview::default();
preview.pty = None;
let mut tui =
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
.unwrap();
preview.spawn(&mut tui, "printf streamed; sleep 30").unwrap();
let started = Instant::now();
let streamed_in_time = loop {
if preview_contains(&preview, "streamed") {
break true;
}
if started.elapsed() >= Duration::from_secs(2) {
break false;
}
std::thread::sleep(Duration::from_millis(10));
};
preview.kill();
preview.thread_handle.take().unwrap().join().unwrap();
assert!(streamed_in_time, "preview output did not stream");
}
#[cfg(unix)]
#[test]
fn stale_plain_preview_cannot_replace_newer_streamed_output() {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
let mut preview = Preview::default();
preview.pty = None;
let mut tui =
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
.unwrap();
preview.spawn(&mut tui, "printf stale; sleep 30").unwrap();
let stale_cancelled = preview.plain_cancelled.as_ref().unwrap().clone();
let stale_thread = preview.thread_handle.take().unwrap();
preview.spawn(&mut tui, "printf current; sleep 30").unwrap();
let started = Instant::now();
let current_streamed = loop {
if preview_contains(&preview, "current") {
break true;
}
if started.elapsed() >= Duration::from_secs(2) {
break false;
}
std::thread::sleep(Duration::from_millis(10));
};
update_plain_content(&preview.content, &stale_cancelled, b"stale");
let stale_write_was_ignored = preview_contains(&preview, "current") && !preview_contains(&preview, "stale");
preview.kill();
preview.thread_handle.take().unwrap().join().unwrap();
stale_thread.join().unwrap();
assert!(current_streamed, "new preview output did not stream");
assert!(stale_write_was_ignored, "stale preview replaced newer output");
}
#[cfg(unix)]
#[test]
fn plain_preview_can_be_cancelled() {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
let mut preview = Preview::default();
preview.pty = None;
let mut tui =
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
.unwrap();
preview.spawn(&mut tui, "sleep 30").unwrap();
let started = Instant::now();
preview.kill();
preview.thread_handle.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_secs(2));
}
#[test]
fn vertical_scroll_clamps_to_content() {
let mut p = Preview::default();

View file

@ -251,6 +251,123 @@ impl Drop for RawMode {
}
}
/// Detect the terminal's image protocol and cell size through its controlling TTY.
///
/// Unlike `Picker::from_query_stdio`, this does not read from standard input, which may be the
/// item stream when skim is used in a pipeline. The caller must have put the terminal in raw mode.
#[cfg(all(feature = "image", unix))]
pub(crate) fn detect_image_picker() -> eyre::Result<ratatui_image::picker::Picker> {
use std::env;
use std::os::fd::AsRawFd as _;
use std::time::Instant;
use eyre::eyre;
use nix::sys::time::{suseconds_t, time_t};
use ratatui_image::FontSize;
use ratatui_image::picker::cap_parser::{Parser, QueryStdioOptions, Response};
use ratatui_image::picker::{Picker, ProtocolType};
let mut tty = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits())
.open("/dev/tty")?;
let mut options = QueryStdioOptions::default();
let is_wezterm = env::var("WEZTERM_EXECUTABLE").is_ok_and(|value| !value.is_empty());
let is_konsole = env::var("KONSOLE_VERSION").is_ok_and(|value| !value.is_empty());
if is_wezterm || is_konsole {
options.blacklist_protocols = vec![ProtocolType::Kitty, ProtocolType::Sixel];
}
let timeout = options.timeout;
let is_tmux = env::var("TMUX").is_ok_and(|value| !value.is_empty());
tty.write_all(Parser::query(is_tmux, options).as_bytes())?;
tty.flush()?;
let deadline = Instant::now() + timeout;
let mut parser = Parser::new();
let mut responses = Vec::new();
'query: loop {
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
return Err(eyre!("terminal image protocol detection timed out"));
};
let micros = i32::try_from(remaining.as_micros()).unwrap_or(i32::MAX);
let mut select_timeout =
nix::sys::time::TimeVal::new(time_t::from(micros / 1_000_000), suseconds_t::from(micros % 1_000_000));
let mut rfds = nix::sys::select::FdSet::new();
rfds.insert(tty.as_fd());
match nix::sys::select::select(
rfds.highest().unwrap().as_raw_fd() + 1,
Some(&mut rfds),
None,
None,
Some(&mut select_timeout),
) {
Ok(0) => return Err(eyre!("terminal image protocol detection timed out")),
Ok(_) => {
let mut buf = [0; 128];
match tty.read(&mut buf) {
Ok(0) => return Err(eyre!("controlling terminal closed during image protocol detection")),
Ok(read) => {
for byte in &buf[..read] {
for response in parser.push(char::from(*byte)) {
if response == Response::Status {
break 'query;
}
responses.push(response);
}
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
Err(err) => return Err(err.into()),
}
}
Err(nix::errno::Errno::EINTR) => {}
Err(err) => return Err(io::Error::from_raw_os_error(err as i32).into()),
}
}
let mut protocol = None;
let mut font_size = None;
for response in responses {
match response {
Response::Kitty => protocol = Some(ProtocolType::Kitty),
Response::Sixel if protocol.is_none() => protocol = Some(ProtocolType::Sixel),
Response::CellSize(Some((width, height))) => font_size = Some(FontSize::new(width, height)),
_ => {}
}
}
let font_size = font_size.or_else(|| {
let size = crossterm::terminal::window_size().ok()?;
if size.width == 0 || size.height == 0 || size.columns == 0 || size.rows == 0 {
return None;
}
Some(FontSize::new(size.width / size.columns, size.height / size.rows))
});
let Some(font_size) = font_size else {
return Ok(Picker::halfblocks());
};
// This deprecated constructor is currently the only public way to set the font size while also
// initializing ratatui-image's private tmux state.
#[allow(deprecated)]
let mut picker = Picker::from_fontsize(font_size);
if let Some(protocol) = protocol {
picker.set_protocol_type(protocol);
}
Ok(picker)
}
/// Detect the image protocol through standard I/O on Windows.
#[cfg(all(feature = "image", windows))]
pub(crate) fn detect_image_picker() -> eyre::Result<ratatui_image::picker::Picker> {
Ok(ratatui_image::picker::Picker::from_query_stdio()?)
}
/// Get cursor position, 1-based
#[cfg(unix)]
pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> {

View file

@ -110,6 +110,28 @@ fn filter_mode_with_print0() {
assert!(stdout.contains('\0'));
}
#[test]
fn filter_mode_inverse_query_checks_every_nth_field() {
// `--nth 1,2` gives each item two matching ranges. An inverse query must
// reject an item when ANY of them contains the term, not just the first one.
let (code, stdout, _) = run_sk_argv("foo bar\nqux bar\n", &["-f", "!foo", "--nth", "1,2"], &[]);
assert_eq!(code, Some(0));
assert!(
!stdout.contains("foo bar"),
"!foo must exclude 'foo bar' (got {stdout:?})"
);
assert!(stdout.contains("qux bar"), "!foo must keep 'qux bar' (got {stdout:?})");
// The term sitting in the second field must be caught too.
let (code, stdout, _) = run_sk_argv("bar foo\nbar qux\n", &["-f", "!foo", "--nth", "1,2"], &[]);
assert_eq!(code, Some(0));
assert!(
!stdout.contains("bar foo"),
"!foo must exclude 'bar foo' (got {stdout:?})"
);
assert!(stdout.contains("bar qux"), "!foo must keep 'bar qux' (got {stdout:?})");
}
#[test]
fn filter_mode_no_sort_preserves_input_order() {
// Workers grab 4096-item chunks from a shared queue, so with enough items
@ -128,6 +150,57 @@ fn filter_mode_no_sort_preserves_input_order() {
);
}
#[test]
fn with_nth_accepts_space_separated_negative_index() {
// `--with-nth -1` (space form) used to be parsed as a missing value, while
// `--nth -1` and `--with-nth=-1` both worked.
let (code, stdout, stderr) = run_sk_argv("a b c", &["-f", "c", "--with-nth", "-1"], &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout.trim_end(), "a b c");
// The space form and the `=` form must agree.
let (code_eq, stdout_eq, _) = run_sk_argv("a b c", &["-f", "c", "--with-nth=-1"], &[]);
assert_eq!((code, stdout), (code_eq, stdout_eq));
}
#[test]
fn nth_index_past_i32_does_not_fall_back_to_field_1() {
// An index too large for i32 used to fail to parse and silently become field 1,
// so `--nth <huge>` matched the first field instead of matching nothing.
let (code_huge, out_huge, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "2147483648"], &[]);
let (code_oob, out_oob, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "5"], &[]);
assert_eq!((code_huge, out_huge.as_str()), (code_oob, out_oob.as_str()));
assert_eq!(code_huge, Some(1), "an out-of-range field must match nothing");
assert!(out_huge.is_empty());
// Same for the `{N}` field syntax in --output-format. Assert the exit status and
// stderr too, so an empty stdout can't pass by way of the placeholder erroring out.
for placeholder in ["{2147483648}", "{-2147483649}"] {
let (code, out, err) = run_sk_argv("a b c", &["-1", "-q", "a", "--output-format", placeholder], &[]);
assert_eq!(code, Some(0), "{placeholder}: stderr: {err}");
assert_eq!(err, "", "{placeholder} should not error");
assert_eq!(out.trim_end(), "", "{placeholder} should render an empty field");
}
}
#[test]
fn pathname_tiebreak_is_not_broken_by_a_non_ascii_directory() {
// `path_name_offset` used to be a byte offset while `Rank::begin` is a char
// index, so a multi-byte directory component inflated the PathName score and
// pushed the filename match below the directory match.
let (code, stdout, stderr) = run_sk_argv("ééééé/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(
stdout.lines().next(),
Some("ééééé/a"),
"the filename match must rank first, got: {stdout:?}"
);
// The all-ASCII shape of the same input already ranked correctly; both must agree.
let (_, ascii_stdout, _) = run_sk_argv("eeeee/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]);
assert_eq!(ascii_stdout.lines().next(), Some("eeeee/a"));
}
#[test]
fn select_1_with_output_format() {
// --output-format renders the selected item through the printf branch.

View file

@ -6,6 +6,10 @@
#[allow(dead_code)]
#[macro_use]
mod common;
use std::io::Cursor;
use skim::prelude::*;
use common::zellij::Keys::*;
sk_test!(sk_version_long, "", &["--version"], {
@ -38,3 +42,69 @@ sk_test!(issue_1120_height_mode_clears_on_exit, @cmd "seq 1 10", &["--height=50%
@keys Key('\x1b');
@lines |l| (!l.iter().any(|line| line.starts_with(">")));
});
sk_test!(min_height_grows_inline_viewport, @cmd "for i in {1..20}; do echo min-height-item-$i; done", &["--height=20%", "--min-height=10"], {
@lines |l| (l.iter().map(|line| line.matches("min-height-item-").count()).sum::<usize>() >= 7);
@keys Escape;
});
#[test]
fn library_builder_min_height_child() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var_os("SKIM_MIN_HEIGHT_BUILDER_CHILD").is_none() {
return Ok(());
}
let options = SkimOptionsBuilder::default().height("20%").min_height("10").build()?;
let items = SkimItemReader::default().of_bufread(Cursor::new(
(1..=20)
.map(|i| format!("builder-min-height-item-{i}"))
.collect::<Vec<_>>()
.join("\n"),
));
Skim::run_with(options, Some(items))?;
Ok(())
}
#[test]
fn library_builder_min_height_resizes_and_scrolls() -> Result<(), Box<dyn std::error::Error>> {
let zellij = common::zellij::ZellijController::new_named("builderminheight")?;
zellij.send_keys(&[Str("printf '\\n%.0s' {1..22}"), Enter])?;
zellij.until(|lines| lines.first().is_some_and(|line| line.starts_with("skim$")))?;
let test_binary = std::env::current_exe()?.to_string_lossy().replace('\\', "/");
let test_binary = format!("'{}'", test_binary.replace('\'', "'\\''"));
let command =
format!("SKIM_MIN_HEIGHT_BUILDER_CHILD=1 {test_binary} --exact library_builder_min_height_child --nocapture");
zellij.send_keys(&[Str(&command), Enter])?;
zellij.until(|lines| {
lines
.iter()
.map(|line| line.matches("builder-min-height-item-").count())
.sum::<usize>()
>= 7
})?;
zellij.send_keys(&[Escape])?;
zellij.until(|lines| lines.iter().any(|line| line.contains("test result: ok")))?;
Ok(())
}
#[test]
fn min_height_scrolls_when_cursor_is_near_terminal_bottom() -> std::io::Result<()> {
let mut zellij = common::zellij::ZellijController::new_named("minheightscroll")?;
zellij.send_keys(&[Str("printf '\\n%.0s' {1..22}"), Enter])?;
zellij.until(|lines| lines.first().is_some_and(|line| line.starts_with("skim$")))?;
zellij.start_sk(
Some("for i in {1..20}; do echo min-height-scroll-item-$i; done"),
&["--height=20%", "--min-height=10"],
)?;
zellij.until(|lines| {
lines
.iter()
.map(|line| line.matches("min-height-scroll-item-").count())
.sum::<usize>()
>= 7
})?;
zellij.send_keys(&[Escape])?;
Ok(())
}

View file

@ -848,7 +848,7 @@ fn opt_multiple_flags_parse() {
"-I {} -I XX",
"--color base --color light",
"--margin 30% --margin 0",
"--min-height 30% --min-height 10",
"--min-height 30 --min-height 10",
"--preview 'ls {}' --preview 'cat {}'",
"--preview-window up --preview-window down",
"--multi -m",

View file

@ -139,9 +139,10 @@ fn tmux_quote() -> Result<()> {
tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;
println!("tmux cmd: {cmd}");
assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E"));
assert!(cmd.contains("--bind ctrl-a':reload(ls /foo*)'"));
assert!(cmd.contains("--bind ctrl-a:reload'(ls /foo*)'"));
assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;"));
Ok(())