From 1ee19d3caa5f003ebd638964a06a2500198e1a72 Mon Sep 17 00:00:00 2001 From: LoricAndre <57358788+LoricAndre@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:35:30 +0100 Subject: [PATCH] feat: split-match (#906) * wip: split-match * chore: generate completions & manpage * chore: use engine factory for split match * chore: generate completions & manpage * docs: update README.md with new option [skip ci] --------- Co-authored-by: Skim bot --- .config/insta.yaml | 5 + .github/CONTRIBUTING.md | 2 +- README.md | 4 + man/man1/sk.1 | 7 +- shell/completion.bash | 6 +- shell/completion.fish | 1 + shell/completion.nu | 1 + shell/completion.zsh | 1 + src/engine/factory.rs | 21 +-- src/engine/mod.rs | 1 + src/engine/split.rs | 151 ++++++++++++++++++ src/matcher.rs | 11 +- src/options.rs | 16 ++ ...match__insta_split_match_both_parts-2.snap | 28 ++++ ...t_match__insta_split_match_both_parts.snap | 28 ++++ ..._insta_split_match_custom_delimiter-2.snap | 28 ++++ ...h__insta_split_match_custom_delimiter.snap | 28 ++++ ...t_match_delimiter_in_query_not_item-2.snap | 28 ++++ ...lit_match_delimiter_in_query_not_item.snap | 28 ++++ ...atch__insta_split_match_empty_after-2.snap | 28 ++++ ..._match__insta_split_match_empty_after.snap | 28 ++++ ...tch__insta_split_match_empty_before-2.snap | 28 ++++ ...match__insta_split_match_empty_before.snap | 28 ++++ ...t_match_multiple_delimiters_in_item-2.snap | 28 ++++ ...lit_match_multiple_delimiters_in_item.snap | 28 ++++ ...ta_split_match_no_delimiter_in_item-2.snap | 28 ++++ ...nsta_split_match_no_delimiter_in_item.snap | 28 ++++ .../split_match__insta_split_match_or-2.snap | 28 ++++ .../split_match__insta_split_match_or.snap | 28 ++++ ..._split_match_query_before_delimiter-2.snap | 28 ++++ ...ta_split_match_query_before_delimiter.snap | 28 ++++ tests/split_match.rs | 65 ++++++++ 32 files changed, 774 insertions(+), 22 deletions(-) create mode 100644 .config/insta.yaml create mode 100644 src/engine/split.rs create mode 100644 tests/snapshots/split_match__insta_split_match_both_parts-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_both_parts.snap create mode 100644 tests/snapshots/split_match__insta_split_match_custom_delimiter-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_custom_delimiter.snap create mode 100644 tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item.snap create mode 100644 tests/snapshots/split_match__insta_split_match_empty_after-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_empty_after.snap create mode 100644 tests/snapshots/split_match__insta_split_match_empty_before-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_empty_before.snap create mode 100644 tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item.snap create mode 100644 tests/snapshots/split_match__insta_split_match_no_delimiter_in_item-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_no_delimiter_in_item.snap create mode 100644 tests/snapshots/split_match__insta_split_match_or-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_or.snap create mode 100644 tests/snapshots/split_match__insta_split_match_query_before_delimiter-2.snap create mode 100644 tests/snapshots/split_match__insta_split_match_query_before_delimiter.snap create mode 100644 tests/split_match.rs diff --git a/.config/insta.yaml b/.config/insta.yaml new file mode 100644 index 00000000..afa0cf94 --- /dev/null +++ b/.config/insta.yaml @@ -0,0 +1,5 @@ +behavior: + output: minimal + +test: + runner: nextest diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fc5d3104..8febde09 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,7 +8,7 @@ You will need `tmux` to run some integration tests. You can then run `cargo nextest run --release --features test-utils`, which should automatically build a release binary, run the unit tests and the integration tests. -Most integration tests use [cargo insta](https://insta.rs). If you need to add some tests or re-review them, you will need to install it. +Most integration tests use [cargo insta](https://insta.rs). If you need to add some tests or re-review them, you will need to install it, and run tests with `cargo insta test --features test-utils --tests --review`, which will let you review snapshots. Note: you can run the tests without `--release`, but expect more flaky tests since the timings will be looser. I would advise testing manually any debug test failure if you have doubts. However, the tests won't run without the `test-utils` feature, used to create test backends. diff --git a/README.md b/README.md index 7bbed62e..40992112 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,10 @@ page](https://github.com/skim-rs/skim/blob/master/man/man1/sk.1) (`man sk`). - `OR` has higher precedence. For example, `readme .md$ | .markdown$` is interpreted as `readme AND (.md$ OR .markdown$)`. +- When using the `--split-match` option, each part around spaces or `|` will be matched in a split way: + - If the option's value (defaulting to `:`) is absent from the query, do a normal match + - If it is present, match everything before to everything before it in the items, and everything after it (including potential other occurences of the delimiter) to the part after it in the items. This is particularly useful when piping in input from `rg` to match on both file name and content. + If you prefer using regular expressions, `skim` offers a `regex` mode: ```sh diff --git a/man/man1/sk.1 b/man/man1/sk.1 index 493dc865..4703990d 100644 --- a/man/man1/sk.1 +++ b/man/man1/sk.1 @@ -4,7 +4,7 @@ .SH NAME sk \- Fuzzy Finder in rust! .SH SYNOPSIS -\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-tmux\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] +\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-split\-match\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-tmux\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] .SH DESCRIPTION sk \- fuzzy finder in Rust .PP @@ -197,6 +197,11 @@ ignore: Case\-insensitive matching .IP \(bu 2 smart: Smart case: case\-insensitive unless query contains uppercase .RE +.TP +\fB\-\-split\-match\fR [\fI...\fR] +Enable split matching and set delimiter + +Split matching runs the matcher in splits: foo:bar will match all items matching foo, then :, then bar if the delimiter is present, or match normally if not. .SH INTERFACE .TP \fB\-b\fR, \fB\-\-bind\fR [\fI...\fR] [default: ] diff --git a/shell/completion.bash b/shell/completion.bash index 724186fb..f000ddc3 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -23,7 +23,7 @@ _sk() { case "${cmd}" in sk) - opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --bind --multi --no-multi --no-mouse --cmd --interactive --color --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --info --no-info --inline-info --header --header-lines --border --wrap --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --tmux --log-file --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --expect --help --version" + opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --split-match --bind --multi --no-multi --no-mouse --cmd --interactive --color --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --info --no-info --inline-info --header --header-lines --border --wrap --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --tmux --log-file --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --expect --help --version" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -69,6 +69,10 @@ _sk() { COMPREPLY=($(compgen -W "respect ignore smart" -- "${cur}")) return 0 ;; + --split-match) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --bind) COMPREPLY=($(compgen -f "${cur}")) return 0 diff --git a/shell/completion.fish b/shell/completion.fish index 5a3dce45..45f82896 100644 --- a/shell/completion.fish +++ b/shell/completion.fish @@ -19,6 +19,7 @@ frizbee\t'Frizbee matching algorithm, typo resistant Will fallback to SkimV2 if complete -c sk -l case -d 'Case sensitivity' -r -f -a "respect\t'Case-sensitive matching' ignore\t'Case-insensitive matching' smart\t'Smart case: case-insensitive unless query contains uppercase'" +complete -c sk -l split-match -d 'Enable split matching and set delimiter' -r complete -c sk -s b -l bind -d 'Comma separated list of bindings' -r complete -c sk -s c -l cmd -d 'Command to invoke dynamically in interactive mode' -r complete -c sk -s I -d 'Replace replstr with the selected item in commands' -r diff --git a/shell/completion.nu b/shell/completion.nu index 25c8f7f9..ccb076df 100644 --- a/shell/completion.nu +++ b/shell/completion.nu @@ -37,6 +37,7 @@ module completions { --regex # Start in regex mode instead of fuzzy-match --algo: string@"nu-complete sk algorithm" # Fuzzy matching algorithm --case: string@"nu-complete sk case" # Case sensitivity + --split-match: string # Enable split matching and set delimiter --bind(-b): string # Comma separated list of bindings --multi(-m) # Enable multiple selection --no-multi # Disable multiple selection diff --git a/shell/completion.zsh b/shell/completion.zsh index 9ea0e143..f484f758 100644 --- a/shell/completion.zsh +++ b/shell/completion.zsh @@ -30,6 +30,7 @@ frizbee\:"Frizbee matching algorithm, typo resistant Will fallback to SkimV2 if '--case=[Case sensitivity]:CASE:((respect\:"Case-sensitive matching" ignore\:"Case-insensitive matching" smart\:"Smart case\: case-insensitive unless query contains uppercase"))' \ +'--split-match=[Enable split matching and set delimiter]::SPLIT_MATCH:_default' \ '*-b+[Comma separated list of bindings]::BIND:_default' \ '*--bind=[Comma separated list of bindings]::BIND:_default' \ '-c+[Command to invoke dynamically in interactive mode]:CMD:_default' \ diff --git a/src/engine/factory.rs b/src/engine/factory.rs index addc0275..3040ccb6 100644 --- a/src/engine/factory.rs +++ b/src/engine/factory.rs @@ -64,24 +64,13 @@ impl MatchEngineFactory for ExactOrFuzzyEngineFactory { // !^abc$ => not "abc" let mut query = query; - let mut exact = false; + let mut exact = self.exact_mode; let mut param = ExactMatchingParam::default(); param.case = case; if query.starts_with('\'') { - if self.exact_mode { - return Box::new( - FuzzyEngine::builder() - .query(&query[1..]) - .algorithm(self.fuzzy_algorithm) - .case(case) - .rank_builder(self.rank_builder.clone()) - .build(), - ); - } else { - exact = true; - query = &query[1..]; - } + exact = !exact; + query = &query[1..]; } if query.starts_with('!') { @@ -111,10 +100,6 @@ impl MatchEngineFactory for ExactOrFuzzyEngineFactory { param.postfix = true; } - if self.exact_mode { - exact = true; - } - if exact { Box::new( ExactEngine::builder(query, param) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 52ba73ed..6192909c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4,4 +4,5 @@ pub mod exact; pub mod factory; pub mod fuzzy; pub mod regexp; +pub mod split; mod util; diff --git a/src/engine/split.rs b/src/engine/split.rs new file mode 100644 index 00000000..6d38cb9c --- /dev/null +++ b/src/engine/split.rs @@ -0,0 +1,151 @@ +//! Split match engine for matching against different parts of items based on a delimiter. +//! +//! This engine splits both the query and item text on a delimiter character, then matches +//! the query parts against the corresponding item parts. + +use std::fmt::{Display, Error, Formatter}; +use std::sync::Arc; + +use crate::{MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem}; + +/// Engine that matches by splitting query and item on a delimiter +pub struct SplitMatchEngine { + /// The engine to match the "before delimiter" part + before_engine: Box, + /// The engine to match the "after delimiter" part + after_engine: Box, + /// The delimiter character used for splitting + delimiter: char, +} + +impl SplitMatchEngine { + /// Creates a new split match engine + pub fn new(before_engine: Box, after_engine: Box, delimiter: char) -> Self { + Self { + before_engine, + after_engine, + delimiter, + } + } +} + +impl MatchEngine for SplitMatchEngine { + fn match_item(&self, item: Arc) -> Option { + let text = item.text(); + + // Find the delimiter in the item text (by char position) + let delimiter_char_idx = text.chars().position(|c| c == self.delimiter)?; + + // Get byte position for slicing + let delimiter_byte_pos = text.char_indices().nth(delimiter_char_idx).map(|(i, _)| i)?; + + let text_before = &text[..delimiter_byte_pos]; + let text_after = &text[delimiter_byte_pos + self.delimiter.len_utf8()..]; + + // Create wrapper items for each part + let before_item: Arc = Arc::new(StringItem(text_before.to_string())); + let after_item: Arc = Arc::new(StringItem(text_after.to_string())); + + // Match both parts + let before_result = self.before_engine.match_item(before_item)?; + let after_result = self.after_engine.match_item(after_item)?; + + // Combine the results - use rank from first result (like AndEngine does) + let rank = before_result.rank; + + let mut combined_indices: Vec = match before_result.matched_range { + MatchRange::Chars(indices) => indices, + MatchRange::ByteRange(start, end) => { + // Convert byte range to char indices for the before part + text_before + .char_indices() + .enumerate() + .filter(|(_, (byte_idx, _))| *byte_idx >= start && *byte_idx < end) + .map(|(char_idx, _)| char_idx) + .collect() + } + }; + + // Offset for the "after" part: delimiter_char_idx + 1 (to skip the delimiter) + let offset = delimiter_char_idx + 1; + + let after_indices: Vec = match after_result.matched_range { + MatchRange::Chars(indices) => indices.into_iter().map(|i| i + offset).collect(), + MatchRange::ByteRange(start, end) => { + // Convert byte range to char indices for the after part + text_after + .char_indices() + .enumerate() + .filter(|(_, (byte_idx, _))| *byte_idx >= start && *byte_idx < end) + .map(|(char_idx, _)| char_idx + offset) + .collect() + } + }; + + combined_indices.extend(after_indices); + combined_indices.sort(); + combined_indices.dedup(); + + Some(MatchResult { + rank, + matched_range: MatchRange::Chars(combined_indices), + }) + } +} + +impl Display for SplitMatchEngine { + fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { + write!( + f, + "(Split[{}]: {} | {})", + self.delimiter, self.before_engine, self.after_engine + ) + } +} + +/// Simple string wrapper implementing SkimItem for split matching +struct StringItem(String); + +impl SkimItem for StringItem { + fn text(&self) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(&self.0) + } +} + +//------------------------------------------------------------------------------ +// SplitMatchEngineFactory - wraps another factory and handles split matching + +/// Factory that handles split matching by wrapping another engine factory +pub struct SplitMatchEngineFactory { + inner: Box, + delimiter: char, +} + +impl SplitMatchEngineFactory { + /// Creates a new split match engine factory + pub fn new(inner: impl MatchEngineFactory + 'static, delimiter: char) -> Self { + Self { + inner: Box::new(inner), + delimiter, + } + } +} + +impl MatchEngineFactory for SplitMatchEngineFactory { + fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box { + // Check if the query contains the delimiter + if let Some(delimiter_pos) = query.find(self.delimiter) { + let query_before = &query[..delimiter_pos]; + let query_after = &query[delimiter_pos + self.delimiter.len_utf8()..]; + + // Create engines for each part using the inner factory + let before_engine = self.inner.create_engine_with_case(query_before, case); + let after_engine = self.inner.create_engine_with_case(query_after, case); + + Box::new(SplitMatchEngine::new(before_engine, after_engine, self.delimiter)) + } else { + // No delimiter in query, pass through to inner factory + self.inner.create_engine_with_case(query, case) + } + } +} diff --git a/src/matcher.rs b/src/matcher.rs index a87279ea..9e7ecdfb 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -4,6 +4,7 @@ use std::thread; use rayon::prelude::*; +use crate::engine::split::SplitMatchEngineFactory; use crate::item::{ItemPool, MatchedItem, RankBuilder}; use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory}; use crate::spinlock::SpinLock; @@ -94,7 +95,15 @@ impl Matcher { .exact_mode(options.exact) .rank_builder(rank_builder) .build(); - Rc::new(AndOrEngineFactory::new(fuzzy_engine_factory)) + + // If split_match is enabled, wrap the fuzzy factory with SplitMatchEngineFactory + // Then wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work + if let Some(delimiter) = options.split_match { + let split_factory = SplitMatchEngineFactory::new(fuzzy_engine_factory, delimiter); + Rc::new(AndOrEngineFactory::new(split_factory)) + } else { + Rc::new(AndOrEngineFactory::new(fuzzy_engine_factory)) + } }; Matcher::builder(engine_factory).case(options.case).build() diff --git a/src/options.rs b/src/options.rs index d4157fef..4a8262d2 100644 --- a/src/options.rs +++ b/src/options.rs @@ -247,6 +247,21 @@ pub struct SkimOptions { )] pub case: CaseMatching, + /// Enable split matching and set delimiter + /// + /// Split matching runs the matcher in splits: `foo:bar` will match all items matching `foo`, then + /// `:`, then `bar` if the delimiter is present, or match normally if not. + #[cfg_attr( + feature = "cli", + arg( + long, + default_missing_value = ":", + help_heading = "Search", + num_args=0.. + ) + )] + pub split_match: Option, + // --- Interface --- /// Comma separated list of bindings /// @@ -1125,6 +1140,7 @@ pub struct SkimOptions { impl Default for SkimOptions { fn default() -> Self { Self { + split_match: None, no_strip_ansi: false, wrap_items: false, listen: None, diff --git a/tests/snapshots/split_match__insta_split_match_both_parts-2.snap b/tests/snapshots/split_match__insta_split_match_both_parts-2.snap new file mode 100644 index 00000000..20096c41 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_both_parts-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> foo:bar " +" 1/3 0/0" +"> foo:bar " diff --git a/tests/snapshots/split_match__insta_split_match_both_parts.snap b/tests/snapshots/split_match__insta_split_match_both_parts.snap new file mode 100644 index 00000000..ab05ab69 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_both_parts.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" foo:qux " +" baz:qux " +"> foo:bar " +" 3/3 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_custom_delimiter-2.snap b/tests/snapshots/split_match__insta_split_match_custom_delimiter-2.snap new file mode 100644 index 00000000..257a91db --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_custom_delimiter-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> foo/bar " +" 1/2 0/0" +"> foo/bar " diff --git a/tests/snapshots/split_match__insta_split_match_custom_delimiter.snap b/tests/snapshots/split_match__insta_split_match_custom_delimiter.snap new file mode 100644 index 00000000..365a9f00 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_custom_delimiter.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" baz/qux " +"> foo/bar " +" 2/2 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item-2.snap b/tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item-2.snap new file mode 100644 index 00000000..f8fc4888 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" 0/2 0/0" +"> foo:bar " diff --git a/tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item.snap b/tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item.snap new file mode 100644 index 00000000..13ad99f8 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_delimiter_in_query_not_item.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" bazqux " +"> foobar " +" 2/2 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_empty_after-2.snap b/tests/snapshots/split_match__insta_split_match_empty_after-2.snap new file mode 100644 index 00000000..69a58f47 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_empty_after-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" foo:qux " +"> foo:bar " +" 2/2 0/0" +"> foo: " diff --git a/tests/snapshots/split_match__insta_split_match_empty_after.snap b/tests/snapshots/split_match__insta_split_match_empty_after.snap new file mode 100644 index 00000000..9a46ea7f --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_empty_after.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" foo:qux " +"> foo:bar " +" 2/2 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_empty_before-2.snap b/tests/snapshots/split_match__insta_split_match_empty_before-2.snap new file mode 100644 index 00000000..72dc93e8 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_empty_before-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" baz:bar " +"> foo:bar " +" 2/2 0/0" +"> :bar " diff --git a/tests/snapshots/split_match__insta_split_match_empty_before.snap b/tests/snapshots/split_match__insta_split_match_empty_before.snap new file mode 100644 index 00000000..420a89c5 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_empty_before.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" baz:bar " +"> foo:bar " +" 2/2 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item-2.snap b/tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item-2.snap new file mode 100644 index 00000000..97a25d66 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" a:bc:cd " +"> a:b:c " +" 2/3 0/0" +"> a:b:c " diff --git a/tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item.snap b/tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item.snap new file mode 100644 index 00000000..a660af8e --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_multiple_delimiters_in_item.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" a:bc:cd " +" x:y:z " +"> a:b:c " +" 3/3 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_no_delimiter_in_item-2.snap b/tests/snapshots/split_match__insta_split_match_no_delimiter_in_item-2.snap new file mode 100644 index 00000000..dd84d5c9 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_no_delimiter_in_item-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +"> foobar " +" 1/2 0/0" +"> foo " diff --git a/tests/snapshots/split_match__insta_split_match_no_delimiter_in_item.snap b/tests/snapshots/split_match__insta_split_match_no_delimiter_in_item.snap new file mode 100644 index 00000000..13ad99f8 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_no_delimiter_in_item.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" bazqux " +"> foobar " +" 2/2 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_or-2.snap b/tests/snapshots/split_match__insta_split_match_or-2.snap new file mode 100644 index 00000000..8cbe87b6 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_or-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" x:yz " +"> a:bc " +" 2/3 0/0" +"> a:b | x:y " diff --git a/tests/snapshots/split_match__insta_split_match_or.snap b/tests/snapshots/split_match__insta_split_match_or.snap new file mode 100644 index 00000000..d89cd97a --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_or.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" z:ab " +" x:yz " +"> a:bc " +" 3/3 0/0" +"> " diff --git a/tests/snapshots/split_match__insta_split_match_query_before_delimiter-2.snap b/tests/snapshots/split_match__insta_split_match_query_before_delimiter-2.snap new file mode 100644 index 00000000..69f81fb9 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_query_before_delimiter-2.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" fbaz:boo " +" baz:foo " +" foo:qux " +"> foo:bar " +" 4/5 0/0" +"> foo " diff --git a/tests/snapshots/split_match__insta_split_match_query_before_delimiter.snap b/tests/snapshots/split_match__insta_split_match_query_before_delimiter.snap new file mode 100644 index 00000000..9e769cc7 --- /dev/null +++ b/tests/snapshots/split_match__insta_split_match_query_before_delimiter.snap @@ -0,0 +1,28 @@ +--- +source: tests/split_match.rs +expression: h.buffer_view() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" fbaz:boo " +" baz:foo " +" foo:qux " +" baz:qux " +"> foo:bar " +" 5/5 0/0" +"> " diff --git a/tests/split_match.rs b/tests/split_match.rs new file mode 100644 index 00000000..26b35ac5 --- /dev/null +++ b/tests/split_match.rs @@ -0,0 +1,65 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +// Test 1: Basic split match - query without delimiter matches before delimiter in item +insta_test!(insta_split_match_query_before_delimiter, ["foo:bar", "baz:qux", "foo:qux", "baz:foo", "fbaz:boo"], &["--split-match", ":"], { + @snap; + @type "foo"; + @snap; +}); + +// Test 2: Query with delimiter matches both parts +insta_test!(insta_split_match_both_parts, ["foo:bar", "baz:qux", "foo:qux"], &["--split-match", ":"], { + @snap; + @type "foo:bar"; + @snap; +}); + +// Test 3: Query with delimiter - empty before, match after +insta_test!(insta_split_match_empty_before, ["foo:bar", "baz:bar"], &["--split-match", ":"], { + @snap; + @type ":bar"; + @snap; +}); + +// Test 4: Query with delimiter - match before, empty after +insta_test!(insta_split_match_empty_after, ["foo:bar", "foo:qux"], &["--split-match", ":"], { + @snap; + @type "foo:"; + @snap; +}); + +// Test 5: Item without delimiter - query without delimiter matches whole item +insta_test!(insta_split_match_no_delimiter_in_item, ["foobar", "bazqux"], &["--split-match", ":"], { + @snap; + @type "foo"; + @snap; +}); + +// Test 6: Item without delimiter - query with delimiter doesn't match +insta_test!(insta_split_match_delimiter_in_query_not_item, ["foobar", "bazqux"], &["--split-match", ":"], { + @snap; + @type "foo:bar"; + @snap; +}); + +// Test 7: Multiple delimiters in item - only first one is used for splitting +insta_test!(insta_split_match_multiple_delimiters_in_item, ["a:b:c", "x:y:z", "a:bc:cd"], &["--split-match", ":"], { + @snap; + @type "a:b:c"; + @snap; +}); + +// Test 8: Custom delimiter (/) +insta_test!(insta_split_match_custom_delimiter, ["foo/bar", "baz/qux"], &["--split-match", "/"], { + @snap; + @type "foo/bar"; + @snap; +}); + +insta_test!(insta_split_match_or, ["a:bc", "x:yz", "z:ab"], &["--split-match", ":"], { + @snap; + @type "a:b | x:y"; + @snap; +});