feat: add cargo-fuzz targets for hand-rolled text parsers (#1106)

* feat: add cargo-fuzz targets for hand-rolled text parsers

Skim's most panic-prone code is the hand-written byte/char-index
bookkeeping over untrusted input: ANSI stripping, --nth/--with-nth field
extraction, the fuzzy matching algorithms, the query->engine->match
pipeline, and the --bind key-map parser. Add five cargo-fuzz (libFuzzer)
targets covering each, asserting real invariants (char-boundary safety,
monotonic index mappings, match indices in bounds) rather than just
catching panics, plus a CI workflow that runs them on every push/PR
touching src/ or fuzz/ and a longer nightly session via cron.

* ci: wire fuzz targets into the existing test matrix

Replace the standalone fuzz.yml workflow with a `fuzz` job in the main
test.yml matrix, running each of the 5 fuzz targets for 60s (5 minutes
total per CI run) alongside nextest/clippy/msrv.

* ci: remove standalone fuzz workflow

Superseded by the fuzz job now in test.yml.

* ci: run fuzz job as a single job on the platform matrix

Reuse the existing linux/macos/windows matrix instead of a separate
per-target matrix; run all 5 fuzz targets sequentially in one step
(60s each, 5 minutes total). cargo-fuzz doesn't support Windows, so
the fuzzing step is skipped there while still installing the
toolchain for consistency with the rest of the matrix.

* ci: reuse existing yaml anchors in the fuzz job

Use *toolchain instead of a bespoke nightly-install step, matching
the coverage job's pattern of letting `cargo +nightly` auto-provision
the toolchain on demand.

* nix: add cargo-fuzz to the tests devShell

Makes cargo-fuzz available via `nix develop` alongside the other test
tooling, matching what CI installs for the fuzz job.

* ci: install cargo-fuzz via taiki-e/install-action

Matches how the other CI-only cargo subcommands (nextest, cargo-msrv,
cargo-llvm-cov) are installed, and is faster than compiling it from
source with cargo install.

* ci: force the native host target for cargo-fuzz

cargo-fuzz was picking a statically-linked musl target on the runner,
which fails since ASan can't link against a static libc. Pass the
actual host triple (from `rustc -vV`) explicitly so the sanitizer
build always targets the dynamically-linked gnu/darwin toolchain.

* ci: skip the whole fuzz job on windows

Rather than skipping just the fuzzing step, exclude the job entirely
for the windows-latest matrix entry via a job-level `if`, since
cargo-fuzz/libFuzzer has no Windows support.

* ci: gate the fuzz job with runner.os instead of matrix.os

Matches the runner.os-based conditionals already used elsewhere in
this workflow (the linux/macos dependency install steps) rather than
comparing matrix.os directly.

* ci: enable the fuzz job on windows without ASan

cargo-fuzz does support Windows, but AddressSanitizer on the MSVC
target needs the separate "C++ AddressSanitizer" VS component plus a
PATH tweak for its DLL, which this runner doesn't have configured.
Rather than skip the job, disable the sanitizer on Windows only
(--sanitizer none) and keep coverage-guided fuzzing there; our
targets assert via plain Rust panics so they don't depend on ASan.

* test: assert exact char_idx correctness in ansi_strip fuzz target

Replace the bounds-only char_idx check with an exact-equality check
against the char position of byte_pos in the original string. This
subsumes (and is stronger than) the monotonicity CodeRabbit flagged,
since strictly-increasing byte positions on char boundaries always
imply strictly-increasing char positions.

* ci: skip windows in fuzz job, scope job permissions

CI showed the Windows fuzz build fails with a real MSVC linker error
(LNK2001: unresolved __start/__stop___sancov_pcs) even with
--sanitizer none: MSVC's linker doesn't synthesize the section
boundary symbols that libFuzzer's coverage instrumentation requires,
so this is unrelated to the earlier ASan/PATH discussion and isn't
fixable by a sanitizer flag. Skip Windows via step-level `if`
(job-level `if` can't reference runner/matrix contexts). Also add an
explicit contents:read permissions block to the job.

* fix(fzy): fix unicode case-folding inconsistency causing overflow panic

The new fuzzy_match fuzz target found a real crash: FzyMatcher panicked
with "attempt to multiply with overflow" on choice="ű\0\0\0\u{1e}ű",
pattern="Űű".

Root cause: fzy_score's case-insensitive comparison used
char::to_ascii_lowercase (a no-op on non-ASCII letters like Ű/ű), while
the shared cheap_matches() prefilter (and the other matchers) use the
Unicode-aware char_equal(). This let cheap_matches accept a pattern
that fzy_score's own DP could then never actually align, since needle
char 'Ű' never matched any haystack position under ASCII-only folding.
The DP's SCORE_MIN sentinel ("impossible") isn't an absorbing element
under plain integer addition, so the broken alignment accumulated to a
value close to, but not exactly, SCORE_MIN, which then overflowed on
the final *SCORE_TO_SKIM conversion since only the exact sentinel was
special-cased.

Fix is_match to use the shared char_equal() so fzy.rs's case folding
matches cheap_matches and the other two matchers (skim.rs, clangd.rs
already do this). Also switch internal_to_skim_score to saturating_mul
as defense in depth, since fzy_score structurally always returns
Some(..) and has no other way to signal "no valid alignment" to the
caller.

* ci: try lld-link to get windows fuzzing working (no ASan)

MSVC ASan is documented broken on GitHub-hosted Windows runners
(actions/runner-images#8891 — ASan binaries crash with
STATUS_DLL_INIT_FAILED even with the runtime DLL on PATH, unresolved
upstream), so it's not viable here regardless of our config. Separately,
the sancov coverage instrumentation cargo-fuzz needs doesn't link with
MSVC's link.exe at all (missing __start/__stop section symbols).
Try switching the Windows leg to rustc's bundled LLD linker
(-C linker-flavor=lld-link -C link-self-contained=+linker) with
--sanitizer none, to at least get coverage-guided fuzzing (no ASan)
working there. Validating live against this PR's CI.

* ci: revert windows fuzzing attempt, exclude it again

The lld-link experiment ruled out the remaining option: LLD's COFF
driver hit the exact same missing __start/__stop___sancov_* symbols as
MSVC's link.exe. This confirms the section-boundary-symbol synthesis
libFuzzer's coverage instrumentation needs simply isn't implemented
for the COFF/Windows target in current LLVM/rustc — an upstream gap,
not a linker choice or CI config problem. Combined with MSVC ASan
being separately documented broken on GH-hosted Windows runners
(actions/runner-images#8891), there's no remaining avenue to try from
the workflow side. Back to excluding Windows from the fuzz job.

* ci: try windows fuzzing with default sanitizer + msvc dev env

Previous Windows attempts both used --sanitizer none, which removes
the ASan runtime that (on Windows) supplies the __start/__stop section
symbol shims libFuzzer's coverage instrumentation needs -- neither
linker synthesizes those on COFF. That's very likely why they failed
to link. Revert to the default sanitizer (address) and add
ilammy/msvc-dev-cmd to put the MSVC ASan DLL directory on PATH, per
the cargo-fuzz Windows setup guide and actions/runner-images#8891.
Testing live whether this builds, and whether the previously-reported
STATUS_DLL_INIT_FAILED runtime crash still reproduces on this runner
image.

* ci: point cargo at the real MSVC linker on windows

msvc-dev-cmd correctly set up Path, but Git Bash prepends its own
usr/bin ahead of it, so cargo picked up Git's coreutils `link`
(hardlink tool) instead of MSVC's link.exe. Set
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER explicitly using
VCToolsInstallDir (set by msvc-dev-cmd) to sidestep PATH ordering
entirely.

* fix(event): parse_action returns None instead of panicking on missing args

The keymap_parse fuzz target found a real crash: KeyMap::from("/:if-")
panicked ("no arg specified for event if-") since parse_action's
documented behavior was to panic on if-* actions missing their
argument, even though the function already returns Option<Action> and
every other malformed/unrecognized action already resolves to None
via the surrounding parse_action_chain/KeyMap plumbing.

Fixed that case, and while checking for the same pattern elsewhere in
the function found four more reachable panics of the same kind
(add-char, execute, execute-silent, set-preview-cmd, set-query parsed
without their required argument), confirmed each panics via a small
repro before fixing. All now return None like every other malformed
action, consistent with the function's existing contract, instead of
panicking on user-supplied --bind strings.

* ci: add a single aggregate status check for branch rulesets

Add a ci-success job that depends on every other job in the workflow
and fails if any of them failed or were cancelled (tolerating
deploy-coverage-page's expected skip off master). This gives branch
protection / repository rulesets one stable check name to require,
instead of enumerating every matrix leg (nextest (linux), fuzz
(windows), etc.) individually.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LoricAndre 2026-07-04 18:01:46 +02:00 committed by GitHub
parent 8a1f2783a8
commit 026a622a9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2823 additions and 35 deletions

View file

@ -201,3 +201,79 @@ jobs:
tool: cargo-msrv@0.19
- name: MSRV Verify
run: cargo msrv verify
fuzz:
permissions:
contents: read
runs-on: ${{matrix.os}}
strategy:
matrix: *matrix
steps:
- *checkout
- *toolchain
- *cache
- name: Set up MSVC dev environment
# Without a sanitizer, libFuzzer's coverage instrumentation fails to
# link on Windows: neither MSVC's link.exe nor LLD's COFF driver
# synthesize the __start/__stop section-boundary symbols it needs
# (an ELF/Mach-O-only linker feature). MSVC's AddressSanitizer
# runtime provides an equivalent shim for those symbols, so it's
# required for the link to succeed at all, not just extra bug
# detection. It needs its DLL directory on PATH at run time, which
# this action sets up (see
# 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
with:
tool: cargo-fuzz@0.13
- name: Run fuzz targets
shell: bash
# 5 targets * 60s = 5 minutes of fuzzing total per run.
# Force the actual host target: the sanitizer build can't link
# against a statically-linked libc (e.g. if CARGO_BUILD_TARGET
# defaults to a musl target elsewhere in the matrix).
run: |
host_target="$(rustc +nightly -vV | sed -n 's/^host: //p')"
if [ "$RUNNER_OS" = "Windows" ]; then
# Git Bash's own coreutils `link` (for hardlinks) sits ahead of
# the MSVC one on PATH within this shell, so cargo/rustc would
# otherwise invoke the wrong `link.exe`. Point at the real MSVC
# linker explicitly, using the dev env ilammy/msvc-dev-cmd set up.
export CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER="${VCToolsInstallDir}bin\\HostX64\\x64\\link.exe"
fi
for target in ansi_strip field_extract fuzzy_match query_match keymap_parse; do
cargo +nightly fuzz run "$target" --target "$host_target" -- -max_total_time=60
done
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ matrix.build }}
path: fuzz/artifacts/
if-no-files-found: ignore
# Single aggregate status check for branch protection / repository rulesets,
# so they don't need to enumerate every matrix leg by name individually.
ci-success:
name: CI Success
if: always()
needs:
- nextest
- coverage
- deploy-coverage-page
- clippy
- rustfmt
- clippy-no-default-features
- msrv
- fuzz
runs-on: ubuntu-latest
steps:
- name: Check all required jobs succeeded
run: |
echo "${{ toJson(needs) }}"
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" || "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs failed or were cancelled."
exit 1
fi
echo "All required jobs passed (deploy-coverage-page is expected to skip off master)."

View file

@ -12,6 +12,7 @@
2. Run: `TSAN_OPTIONS="detect_deadlocks=1" cargo +nightly nextest run --profile tsan --target x86_64-unknown-linux-gnu`
- Lint: `cargo clippy`
- Format: `cargo +nightly fmt` (check only: `cargo +nightly fmt --check`)
- Fuzz (requires nightly + `cargo install cargo-fuzz`): `cargo +nightly fuzz run <target>` — see `fuzz/README.md` for target list
## Code Style
- Format with 120 char line width (defined in .rustfmt.toml)

View file

@ -31,6 +31,7 @@
cargo-nextest
cargo-insta
cargo-llvm-cov
cargo-fuzz
tmux
];
utils = with pkgs; [

4
fuzz/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target
corpus
artifacts
coverage

2416
fuzz/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

52
fuzz/Cargo.toml Normal file
View file

@ -0,0 +1,52 @@
[package]
name = "skim-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
arbitrary = { version = "1", features = ["derive"] }
libfuzzer-sys = "0.4"
regex = "1"
[dependencies.skim]
path = ".."
default-features = false
[[bin]]
name = "ansi_strip"
path = "fuzz_targets/ansi_strip.rs"
test = false
doc = false
bench = false
[[bin]]
name = "field_extract"
path = "fuzz_targets/field_extract.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzzy_match"
path = "fuzz_targets/fuzzy_match.rs"
test = false
doc = false
bench = false
[[bin]]
name = "query_match"
path = "fuzz_targets/query_match.rs"
test = false
doc = false
bench = false
[[bin]]
name = "keymap_parse"
path = "fuzz_targets/keymap_parse.rs"
test = false
doc = false
bench = false

57
fuzz/README.md Normal file
View file

@ -0,0 +1,57 @@
# Fuzzing
This directory contains [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)
(libFuzzer) targets for skim's hand-written, untrusted-input-facing parsers:
text that flows in from stdin, `--ansi` sequences, `--nth`/`--with-nth` field
specs, the search query syntax, and `--bind` key maps. These are exactly the
places where skim does manual byte/char-index bookkeeping on attacker- or
data-controlled strings, which is the most panic-prone code in the project.
## Targets
| Target | Exercises |
|-------------------|----------------------------------------------------------------------------|
| `ansi_strip` | `helper::item::strip_ansi` — ANSI escape stripping & byte/char index map |
| `field_extract` | `field::{FieldRange, get_string_by_field, parse_matching_fields, parse_transform_fields}``--nth`/`--with-nth` |
| `fuzzy_match` | `fuzzy_matcher::{skim, fzy, clangd}` — the fuzzy matching algorithms |
| `query_match` | `Matcher::create_engine_factory` + `DefaultSkimItem` — the full query → engine → match pipeline (exact/regex/AND-OR/fuzzy, with ANSI) |
| `keymap_parse` | `binds::KeyMap` — the `--bind` key-map parser |
Each target asserts more than "doesn't panic" where a cheap invariant is
available (e.g. reported match indices must be valid char indices into the
matched text, index mappings must stay monotonic and land on char
boundaries).
## Running
Install `cargo-fuzz` (requires a nightly toolchain):
```sh
cargo install cargo-fuzz
```
Run a target:
```sh
cargo +nightly fuzz run ansi_strip
```
Run for a bounded time (useful in CI or for a quick check):
```sh
cargo +nightly fuzz run query_match -- -max_total_time=60
```
## Reproducing a crash
`cargo fuzz run` writes failing inputs to `fuzz/artifacts/<target>/`. Replay one with:
```sh
cargo +nightly fuzz run <target> fuzz/artifacts/<target>/crash-<hash>
```
## Adding a target
Add a new `fuzz_targets/<name>.rs`, register it in `fuzz/Cargo.toml`'s
`[[bin]]` list, and prefer asserting a real invariant of the function under
test (bounds, monotonicity, round-tripping) rather than only catching panics.

View file

@ -0,0 +1,37 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use skim::helper::item::strip_ansi;
// `strip_ansi` hand-parses ESC sequences while tracking a byte/char index
// mapping back to the original string. It is run on every line read from
// stdin when `--ansi` is set, so it must never panic on adversarial input
// and the mapping it returns must stay internally consistent.
fuzz_target!(|input: &str| {
let (stripped, mapping) = strip_ansi(input);
assert_eq!(
mapping.len(),
stripped.chars().count(),
"mapping length must match the number of chars in the stripped string"
);
let mut prev_byte_pos = None;
for &(byte_pos, char_idx) in &mapping {
assert!(
input.is_char_boundary(byte_pos),
"byte_pos {byte_pos} is not a char boundary in the original string"
);
// char_idx must be exactly the char position of byte_pos in the
// original string; this is stronger than (and implies) monotonicity.
let expected_char_idx = input[..byte_pos].chars().count();
assert_eq!(
char_idx, expected_char_idx,
"char_idx must equal the char position of byte_pos in the original string"
);
if let Some(prev) = prev_byte_pos {
assert!(prev < byte_pos, "byte positions in mapping must be strictly increasing");
}
prev_byte_pos = Some(byte_pos);
}
});

View file

@ -0,0 +1,47 @@
#![no_main]
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use regex::Regex;
use skim::field::{FieldRange, get_string_by_field, parse_matching_fields, parse_transform_fields};
// Fuzzes the --nth/--with-nth field range parser and extractor, which slices
// arbitrary user-supplied text on an arbitrary user-supplied delimiter regex.
#[derive(Arbitrary, Debug)]
struct FieldFuzzInput<'a> {
delimiter_pattern: &'a str,
text: &'a str,
range_specs: Vec<&'a str>,
}
fuzz_target!(|input: FieldFuzzInput| {
// Bound the delimiter pattern length so we spend fuzzing time on the
// field logic rather than on the regex engine's own parser.
if input.delimiter_pattern.len() > 32 {
return;
}
let Ok(delimiter) = Regex::new(input.delimiter_pattern) else {
return;
};
let fields: Vec<FieldRange> = input
.range_specs
.iter()
.filter_map(|s| FieldRange::from_str(s))
.collect();
// `fields` can repeat/overlap ranges, so the transformed text is not
// bounded by the input length; just check it doesn't panic.
let _ = parse_transform_fields(&delimiter, input.text, &fields);
for (begin, end) in parse_matching_fields(&delimiter, input.text, &fields) {
assert!(begin <= end, "field range must not be inverted");
assert!(end <= input.text.len(), "field range must stay within the text");
// Slicing must not panic: begin/end must land on char boundaries.
let _ = &input.text[begin..end];
}
for field in &fields {
let _ = get_string_by_field(&delimiter, input.text, field);
}
});

View file

@ -0,0 +1,39 @@
#![no_main]
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use skim::fuzzy_matcher::FuzzyMatcher;
use skim::fuzzy_matcher::clangd::ClangdMatcher;
use skim::fuzzy_matcher::fzy::FzyMatcher;
use skim::fuzzy_matcher::skim::SkimMatcherV2;
// Fuzzes the fuzzy matching algorithms directly on arbitrary unicode
// (choice, pattern) pairs. These run a lot of hand-written index/DP-matrix
// arithmetic over `char` boundaries, so they're prone to panics (overflow,
// out-of-bounds) on adversarial unicode input, and the returned match
// indices must always be valid character indices into `choice`.
#[derive(Arbitrary, Debug)]
struct MatchInput<'a> {
choice: &'a str,
pattern: &'a str,
}
fuzz_target!(|input: MatchInput| {
let skim_matcher = SkimMatcherV2::default();
let fzy_matcher = FzyMatcher::default();
let clangd_matcher = ClangdMatcher::default();
let matchers: [&dyn FuzzyMatcher; 3] = [&skim_matcher, &fzy_matcher, &clangd_matcher];
let num_chars = input.choice.chars().count();
for matcher in matchers {
if let Some((_score, indices)) = matcher.fuzzy_indices(input.choice, input.pattern) {
for &idx in &indices {
assert!(
idx < num_chars,
"match index {idx} out of bounds for choice with {num_chars} chars"
);
}
}
}
});

View file

@ -0,0 +1,10 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use skim::binds::KeyMap;
// Fuzzes the `--bind` key-map parser, which splits an arbitrary user-supplied
// string on commas and colons to build key/action bindings.
fuzz_target!(|input: &str| {
let _ = KeyMap::from(input);
});

View file

@ -0,0 +1,58 @@
#![no_main]
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use regex::Regex;
use skim::helper::item::DefaultSkimItem;
use skim::matcher::Matcher;
use skim::{SkimItem, SkimOptions};
// End-to-end fuzz of the query -> engine -> match pipeline: builds a real
// `DefaultSkimItem` (exercising ANSI stripping / field transforms) and
// matches it with an engine built the same way skim builds it from CLI
// options (exact/regex/andor/fuzzy-algorithm wrapping), using an arbitrary
// query string. Checks that matching never panics and that any reported
// match range stays within the bounds of the text that was actually matched.
#[derive(Arbitrary, Debug)]
struct QueryInput<'a> {
query: &'a str,
text: &'a str,
exact: bool,
regex: bool,
ansi: bool,
case: u8,
}
fuzz_target!(|input: QueryInput| {
// The regex engine path takes the query as a user-supplied pattern; keep
// it short so fuzzing time goes into skim's logic, not regex parsing.
if input.regex && input.query.len() > 32 {
return;
}
let mut options = SkimOptions::default();
options.exact = input.exact;
options.regex = input.regex;
options.case = match input.case % 3 {
0 => skim::CaseMatching::Respect,
1 => skim::CaseMatching::Ignore,
_ => skim::CaseMatching::Smart,
};
let factory = Matcher::create_engine_factory(&options);
let engine = factory.create_engine_with_case(input.query, options.case);
let delimiter = Regex::new(" ").unwrap();
let item = DefaultSkimItem::new(input.text, input.ansi, &[], &[], &delimiter);
if let Some(result) = engine.match_item(&item) {
let matched_text = item.text();
let num_chars = matched_text.chars().count();
for idx in result.range_char_indices(&matched_text) {
assert!(
idx <= num_chars,
"matched char index {idx} out of bounds ({num_chars} chars)"
);
}
}
});

View file

@ -40,7 +40,7 @@ use std::cell::RefCell;
use thread_local::ThreadLocal;
use crate::fuzzy_matcher::util::cheap_matches;
use crate::fuzzy_matcher::util::{char_equal, cheap_matches};
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
// ---------------------------------------------------------------------------
@ -123,20 +123,8 @@ fn precompute_bonus(haystack: &[char]) -> Vec<i64> {
}
#[inline]
fn is_match(
needle: &[char],
haystack: &[char],
lower_needle: &[char],
lower_haystack: &[char],
case_sensitive: bool,
i: usize,
j: usize,
) -> bool {
if case_sensitive {
needle[i] == haystack[j]
} else {
lower_needle[i] == lower_haystack[j]
}
fn is_match(needle: &[char], haystack: &[char], case_sensitive: bool, i: usize, j: usize) -> bool {
char_equal(needle[i], haystack[j], case_sensitive)
}
/// Core fzy scoring without typos.
@ -162,8 +150,6 @@ fn fzy_score(
return Some(SCORE_MAX);
}
let lower_needle: Vec<char> = needle.iter().map(char::to_ascii_lowercase).collect();
let lower_haystack: Vec<char> = haystack.iter().map(char::to_ascii_lowercase).collect();
let match_bonus = precompute_bonus(haystack);
if positions.is_some() {
@ -176,7 +162,7 @@ fn fzy_score(
let mut prev_score = SCORE_MIN;
let gap = if n == 1 { SCORE_GAP_TRAILING } else { SCORE_GAP_INNER };
for j in 0..m {
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, 0, j) {
if is_match(needle, haystack, case_sensitive, 0, j) {
let score = i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j];
d_matrix[0][j] = score;
prev_score = score;
@ -197,7 +183,7 @@ fn fzy_score(
SCORE_GAP_INNER
};
for j in 0..m {
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, i, j) {
if is_match(needle, haystack, case_sensitive, i, j) {
let mut score = SCORE_MIN;
if j > 0 {
let prev_m = m_matrix[i - 1][j - 1];
@ -259,7 +245,7 @@ fn fzy_score(
let old_d = d_row[j];
let old_m = m_row[j];
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, i, j) {
if is_match(needle, haystack, case_sensitive, i, j) {
let score = if i == 0 {
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j]
} else if j > 0 {
@ -687,7 +673,11 @@ fn internal_to_skim_score(score: i64) -> ScoreType {
} else if score == SCORE_MIN {
ScoreType::MIN / 2
} else {
score * SCORE_TO_SKIM
// Saturate rather than panic: the DP sentinel (SCORE_MIN) can end up
// offset by a few accumulated bonuses/penalties along a path that's
// still effectively "no match" (see the fuzz-found case-folding bug
// this fixed), landing close to but not exactly on SCORE_MIN/SCORE_MAX.
score.saturating_mul(SCORE_TO_SKIM)
}
}

View file

@ -300,9 +300,8 @@ pub enum Action {
/// Parses an action string into an Action enum
///
/// # Panics
///
/// Panics if an `if-*` action is specified without its required argument.
/// Returns `None` if the action is unrecognized, or an `if-*` action is
/// specified without its required argument.
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn parse_action(raw_action: &str) -> Option<Action> {
@ -324,7 +323,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
let then_arg;
let mut otherwise_arg = None;
let if_arg = arg.unwrap_or_else(|| panic!("no arg specified for event {action}"));
let if_arg = arg?;
if if_arg.contains('+') {
let split = if_arg.split_once('+');
match split {
@ -346,18 +345,19 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"if-query-not-empty" => Some(Action::IfQueryNotEmpty(then_arg, otherwise_arg)),
_ => None,
}
} else if matches!(
action,
"add-char" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query"
) && arg.is_none()
{
None
} else {
exhaustive_match! {
action => Option<Action>;
{
"abort" => Some(Abort),
"accept" => Some(Accept(arg)),
"add-char" => Some(AddChar(
arg.unwrap_or_default()
.chars()
.next()
.expect("add-char should have an argument"),
)),
"add-char" => Some(AddChar(arg.unwrap_or_default().chars().next().unwrap_or_default())),
"append-and-select" => Some(AppendAndSelect),
"backward-char" => Some(BackwardChar),
"backward-delete-char" => Some(BackwardDeleteChar),
@ -372,8 +372,8 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"deselect-all" => Some(DeselectAll),
"down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
"end-of-line" => Some(EndOfLine),
"execute" => Some(Execute(arg.expect("execute event should have argument"))),
"execute-silent" => Some(ExecuteSilent(arg.expect("execute-silent event should have argument"))),
"execute" => Some(Execute(arg.unwrap_or_default())),
"execute-silent" => Some(ExecuteSilent(arg.unwrap_or_default())),
"first" => Some(First),
"forward-char" => Some(ForwardChar),
"forward-word" => Some(ForwardWord),
@ -405,8 +405,8 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"select-all" => Some(SelectAll),
"select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
"set-header" => Some(SetHeader(arg)),
"set-preview-cmd" => Some(SetPreviewCmd(arg.expect("set-preview-cmd action needs a value"))),
"set-query" => Some(SetQuery(arg.expect("set-query action needs a value"))),
"set-preview-cmd" => Some(SetPreviewCmd(arg.unwrap_or_default())),
"set-query" => Some(SetQuery(arg.unwrap_or_default())),
"toggle" => Some(Toggle),
"toggle-all" => Some(ToggleAll),
"toggle-in" => Some(ToggleIn),