tests: improve coverage to 90% (#1099)

* tests: improve coverage to 90%

* feat: improve coverage

* remove most unix-only tests

* Update src/skim_tests.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fixes

* chore: misc

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
LoricAndre 2026-06-25 21:12:49 +02:00 committed by GitHub
parent 8187a12196
commit 7e2cdf3c8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 10801 additions and 4292 deletions

View file

@ -20,7 +20,7 @@ jobs:
nextest:
runs-on: ${{matrix.os}}
strategy:
matrix:
matrix: &matrix
build: [linux, macos, windows]
include:
- build: linux
@ -34,9 +34,9 @@ jobs:
target: x86_64-pc-windows-msvc
permissions:
contents: read
code-quality: write
steps:
- name: "[linux] Install dependencies"
- &linux-deps
name: "[linux] Install dependencies"
run: |
sudo apt-get install tmux
tmux -V
@ -51,42 +51,25 @@ jobs:
env:
HOMEBREW_NO_AUTO_UPDATE: 1
- name: Checkout repository
- &checkout
name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- run: rustup toolchain install
- uses: taiki-e/install-action@v2
- &toolchain
name: Install rust toolchain
run: rustup toolchain install
- &nextest-install
name: Install nextest
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9
- uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov@0.8
- name: Cache
- &cache
name: Setup cargo cache
uses: Swatinem/rust-cache@v2
with: &cache-with
key: ${{ runner.os }}
add-job-id-key: "false"
add-rust-environment-hash-key: "false"
env-vars: "____"
cache-on-failure: "true"
cache-all-crates: "true"
- name: Run doctests
run: cargo test --doc
- name: "[linux] Run tests with coverage"
if: runner.os == 'Linux'
# Do not use `--all-targets` to avoid running benches
run: |
cargo llvm-cov nextest --release --profile ci --bins --lib --examples --tests --no-report
cargo llvm-cov report --release --cobertura --output-path coverage.xml
cargo llvm-cov report --release --html
echo "COVERAGE_PERCENT=$(cargo llvm-cov report --release | tail -n1 | awk '{ print $4 }')" | tee --append $GITHUB_ENV
env:
LC_ALL: en_US.UTF-8
TERM: xterm-256color
- name: "[macos/windows] Run tests"
if: runner.os != 'Linux'
- name: "Run tests"
# Do not use `--all-targets` to avoid running benches
run: cargo nextest run --release --profile ci --bins --lib --examples --tests
env:
@ -107,89 +90,86 @@ jobs:
fi
done
shell: bash
- name: "[linux] Upload coverage report"
if: runner.os == 'Linux' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
coverage:
runs-on: ubuntu-latest
permissions:
code-quality: write
steps:
- *linux-deps
- *checkout
- *toolchain
- *nextest-install
- uses: taiki-e/install-action@v2
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 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
env:
LC_ALL: en_US.UTF-8
TERM: xterm-256color
- name: "Upload coverage report"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/upload-code-coverage@v1
with:
file: coverage.xml
language: Rust
label: ${{ runner.os }}
- name: "[linux] Generate coverage badge"
if: &if-linux-master runner.os == 'Linux' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
- name: "Generate coverage badge"
if: &if-master github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
uses: emibcn/badge-action@v2.0.2
with:
label: 'Coverage'
status: ${{ env.COVERAGE_PERCENT }}
color: 'blue'
path: 'target/llvm-cov/html/coverage.svg'
- name: "[linux] Upload default branch results to gh pages"
if: *if-linux-master
- name: "Upload default branch results to gh pages"
if: *if-master
uses: actions/upload-pages-artifact@v3
with:
path: target/llvm-cov/html
deploy-coverage-page:
needs: nextest
needs: coverage
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: "[linux] Deploy gh pages"
- name: "Deploy gh pages"
id: deployment
if: *if-linux-master
if: *if-master
uses: actions/deploy-pages@v4
clippy:
runs-on: ${{matrix.os}}
strategy:
matrix:
build: [linux, macos, windows]
include:
- build: linux
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- build: macos
os: macos-latest
target: x86_64-apple-darwin
- build: windows
os: windows-latest
target: x86_64-pc-windows-msvc
matrix: *matrix
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- run: rustup toolchain install
- name: Cache
uses: Swatinem/rust-cache@v2
with: *cache-with
- *checkout
- *toolchain
- *cache
- name: Clippy
run: cargo clippy
rustfmt:
runs-on: ${{matrix.os}}
strategy:
matrix:
build: [linux, macos, windows]
include:
- build: linux
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- build: macos
os: macos-latest
target: x86_64-apple-darwin
- build: windows
os: windows-latest
target: x86_64-pc-windows-msvc
matrix: *matrix
steps:
- name: Checkout repository
uses: actions/checkout@v6
- run: rustup toolchain install
- *checkout
- *toolchain
- name: Check formatting
run: |
cargo fmt --all -- --check
@ -197,25 +177,11 @@ jobs:
build-no-default-features:
runs-on: ${{matrix.os}}
strategy:
matrix:
build: [linux, macos, windows]
include:
- build: linux
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- build: macos
os: macos-latest
target: x86_64-apple-darwin
- build: windows
os: windows-latest
target: x86_64-pc-windows-msvc
matrix: *matrix
steps:
- name: Checkout repository
uses: actions/checkout@v6
- run: rustup toolchain install
- name: Cache
uses: Swatinem/rust-cache@v2
with: *cache-with
- *checkout
- *toolchain
- *cache
- name: Build without any feature
run: |
cargo build --no-default-features
@ -224,11 +190,8 @@ jobs:
msrv:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- run: rustup toolchain install
- *checkout
- *toolchain
- uses: taiki-e/install-action@v2
with:
tool: cargo-msrv@0.19

View file

@ -62,7 +62,8 @@ insta_test!(my_test, @interactive, &["-i", "--cmd", "echo {q}"]);
**DSL variant** (multiple snapshots with interaction between them):
```rust
insta_test!(my_test, ["a", "b", "c"], &["--multi"], {
@snap; // take a snapshot
@snap; // take a snapshot (cell text only)
@snap_color; // snapshot cell styling (fg/bg/modifier) instead
@key Up; // send a named key (Enter, Down, Tab, …)
@char 'f'; // send a single character
@type "foo"; // type a string
@ -83,6 +84,7 @@ insta_test!(my_test, ["a", "b", "c"], &["--multi"], {
|---|---|---|
| Simple variant | `{file}__{test}.snap` | `options__opt_wrap.snap` |
| DSL variant — Nth `@snap` | `{file}__{test}@{NNN}.snap` | `options__opt_cycle@002.snap` |
| DSL variant — Nth `@snap_color` | `{file}__{test}@color{NNN}.snap` | `ansi__ansi_flag_enabled@color002.snap` |
DSL snapshots use a zero-padded three-digit suffix (`@001`, `@002`, …) so that
`cargo insta review` presents them in the order they were taken.

View file

@ -94,7 +94,7 @@ skim/ ← workspace root
│ ├── lib.rs ← library root; re-exports public types
│ ├── skim.rs ← Skim<Backend> orchestrator
│ ├── options.rs ← SkimOptions (all CLI / library options)
│ ├── output.rs ← SkimOutput (returned to callers)
│ ├── output.rs ← SkimOutput (returned to callers) + BinOptions/write_output (CLI serialization)
│ ├── reader.rs ← Reader + ReaderControl + CommandCollector trait
│ ├── matcher.rs ← Matcher + MatcherControl (parallel worker dispatcher)
│ ├── item.rs ← ItemPool, MatchedItem, Rank, RankBuilder
@ -981,14 +981,14 @@ pub struct SkimOutput {
}
```
In the CLI binary, the output phase (`sk_main` after `Skim::run_with`):
1. Prints `query` if `--print-query`
2. Prints `cmd` if `--print-cmd`
3. Prints `header` if `--print-header`
4. Prints current item text if `--print-current`
5. Prints `accept_key` if `--expect` matched
The output phase is `SkimOutput::write_output(&mut out, &BinOptions)` (`src/output.rs`), called by the CLI binary with a buffered stdout. `BinOptions` (also in `src/output.rs`, built via `BinOptions::from_opts`) captures the output-related flags. Keeping the serialization independent of stdout lets it be unit-tested by passing a `Vec<u8>`. It writes, in order:
1. `query` if `--print-query`
2. `cmd` if `--print-cmd`
3. `header` if `--print-header`
4. current item text if `--print-current`
5. `accept_key` if `--expect` matched
6. For each selected item: strips ANSI if `--ansi && !--no-strip-ansi`, prints text + score if `--print-score`
7. If `--output-format <template>`: uses `printf()` to expand a format string with placeholders
7. If `--output-format <template>`: uses `printf()` to expand a format string with placeholders (this path is exclusive — it replaces steps 16)
Exit codes: `0` = items selected, `1` = no items selected, `130` = abort, `135` = tmux launch failed.

View file

@ -87,5 +87,6 @@ pr-review id="":
just generate-files
(git add man/ shell/ && git commit -m 'chore: generate-files' && git push) || echo "Nothing to do"
coverage args="":
cargo llvm-cov nextest --lib --bins --examples --tests {{ args }}
coverage *args="":
cargo +nightly llvm-cov nextest --ignore-run-fail --branch --lib --bins --examples --tests {{ args }}
cargo +nightly llvm-cov report --html

View file

@ -1,6 +1,7 @@
//! Command-line interface for skim fuzzy finder.
//!
//! This binary provides the `sk` command-line tool for fuzzy finding and filtering.
#![cfg_attr(coverage, allow(unused_features), feature(coverage_attribute))]
extern crate clap;
extern crate env_logger;
@ -8,17 +9,14 @@ extern crate log;
extern crate shlex;
extern crate skim;
use crate::Event;
use color_eyre::Result;
use color_eyre::eyre::eyre;
use derive_builder::Builder;
use interprocess::bound_util::RefWrite;
use interprocess::local_socket::ToNsName as _;
use interprocess::local_socket::traits::Stream as _;
use log::trace;
use skim::binds::parse_action_chain;
use skim::reader::CommandCollector;
use skim::tui::event::Action;
use std::fs::File;
use std::io;
use std::io::{BufReader, BufWriter, IsTerminal, Write};
@ -182,64 +180,7 @@ fn sk_main(mut opts: SkimOptions) -> Result<i32> {
{
let stdout = io::stdout();
let mut out = BufWriter::with_capacity(1 << 20, stdout.lock());
if let Some(ref output_format) = bin_options.output_format {
write!(
out,
"{}{}",
skim::printf(
output_format,
&bin_options.delimiter,
&bin_options.replstr,
&result.selected_items.iter(),
&result.current,
&result.query,
&result.cmd,
false
),
bin_options.output_ending
)?;
} else {
if bin_options.print_query {
write!(out, "{}{}", result.query, bin_options.output_ending)?;
}
if bin_options.print_cmd {
write!(out, "{}{}", result.cmd, bin_options.output_ending)?;
}
if bin_options.print_header {
write!(out, "{}{}", result.header, bin_options.output_ending)?;
}
if bin_options.print_current {
if let Some(ref current) = result.current {
write!(out, "{}{}", current.output(), bin_options.output_ending)?;
} else {
write!(out, "{}", bin_options.output_ending)?;
}
}
if let Event::Action(Action::Accept(Some(accept_key))) = result.final_event {
write!(out, "{}{}", accept_key, bin_options.output_ending)?;
}
for item in &result.selected_items {
if bin_options.strip_ansi {
write!(
out,
"{}{}",
skim::helper::item::strip_ansi(&item.output()).0,
bin_options.output_ending
)?;
} else {
write!(out, "{}{}", item.output(), bin_options.output_ending)?;
}
if bin_options.print_score {
write!(out, "{}{}", item.rank.score, bin_options.output_ending)?;
}
}
}
result.write_output(&mut out, &bin_options)?;
out.flush()?;
}
@ -284,34 +225,51 @@ fn write_history_to_file(
Ok(())
}
/// Options specific to the binary/CLI mode
#[derive(Builder)]
#[allow(missing_docs, clippy::struct_excessive_bools)]
pub struct BinOptions {
output_ending: String,
print_query: bool,
print_cmd: bool,
print_score: bool,
print_header: bool,
print_current: bool,
strip_ansi: bool,
output_format: Option<String>,
delimiter: regex::Regex,
replstr: String,
}
impl BinOptions {
fn from_opts(opts: &SkimOptions) -> Self {
Self {
print_query: opts.print_query,
print_cmd: opts.print_cmd,
print_score: opts.print_score,
print_header: opts.print_header,
print_current: opts.print_current,
output_ending: String::from(if opts.print0 { "\0" } else { "\n" }),
strip_ansi: opts.ansi && !opts.no_strip_ansi,
output_format: opts.output_format.clone(),
delimiter: opts.delimiter.clone(),
replstr: opts.replstr.clone(),
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
fn read(path: &std::path::Path) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
#[test]
fn write_history_appends_latest_entry() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hist");
let file_str = file.to_str().unwrap();
write_history_to_file(&["a".to_string(), "b".to_string()], "c", 10, file_str).unwrap();
assert_eq!(read(&file), "a\nb\nc");
}
#[test]
fn write_history_skips_duplicate_of_last() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hist");
let file_str = file.to_str().unwrap();
// The latest equals the last entry → nothing is written, no file created.
write_history_to_file(&["a".to_string(), "b".to_string()], "b", 10, file_str).unwrap();
assert!(!file.exists());
}
#[test]
fn write_history_truncates_to_limit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hist");
let file_str = file.to_str().unwrap();
// limit 2 with 3 existing + 1 new keeps only the newest entries.
write_history_to_file(&["a".to_string(), "b".to_string(), "c".to_string()], "d", 2, file_str).unwrap();
assert_eq!(read(&file), "c\nd");
}
#[test]
fn write_history_empty_latest_does_not_count_towards_limit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hist");
let file_str = file.to_str().unwrap();
// An empty latest adds 0 to the length, so no truncation occurs at limit 3.
write_history_to_file(&["a".to_string(), "b".to_string(), "c".to_string()], "", 3, file_str).unwrap();
assert_eq!(read(&file), "a\nb\nc\n");
}
}

View file

@ -247,135 +247,5 @@ pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
}
#[cfg(test)]
mod tests {
use super::*;
use event::Action::*;
#[test]
fn test_parse_action_chain() {
let parsed = parse_action_chain(
"execute-silent:1 {}+execute-silent:2 {+}+execute-silent:3 {+n}+reload+if-query-empty:reload+up",
);
assert!(parsed.is_ok());
let res = parsed.unwrap();
assert_eq!(
res,
vec![
ExecuteSilent("1 {}".into()),
ExecuteSilent("2 {+}".into()),
ExecuteSilent("3 {+n}".into()),
Reload(None),
IfQueryEmpty("reload".into(), Some("up".into())),
]
);
}
#[test]
fn test_parse_key() {
assert_eq!(
parse_key("a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
);
assert_eq!(
parse_key("A").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("alt-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)
);
assert_eq!(
parse_key("alt-A").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("alt-shift-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("ctrl-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
);
assert_eq!(
parse_key("ctrl-A").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("ctrl-shift-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("f10").unwrap(),
KeyEvent::new(KeyCode::F(10), KeyModifiers::empty())
);
assert_eq!(
parse_key("space").unwrap(),
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty())
);
assert_eq!(
parse_key("enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
);
assert_eq!(
parse_key("bspace").unwrap(),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())
);
assert_eq!(
parse_key("bs").unwrap(),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())
);
assert_eq!(
parse_key("up").unwrap(),
KeyEvent::new(KeyCode::Up, KeyModifiers::empty())
);
assert_eq!(
parse_key("down").unwrap(),
KeyEvent::new(KeyCode::Down, KeyModifiers::empty())
);
assert_eq!(
parse_key("left").unwrap(),
KeyEvent::new(KeyCode::Left, KeyModifiers::empty())
);
assert_eq!(
parse_key("right").unwrap(),
KeyEvent::new(KeyCode::Right, KeyModifiers::empty())
);
assert_eq!(
parse_key("tab").unwrap(),
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())
);
assert_eq!(
parse_key("btab").unwrap(),
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty())
);
assert_eq!(
parse_key("esc").unwrap(),
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
);
assert_eq!(
parse_key("home").unwrap(),
KeyEvent::new(KeyCode::Home, KeyModifiers::empty())
);
assert_eq!(
parse_key("end").unwrap(),
KeyEvent::new(KeyCode::End, KeyModifiers::empty())
);
assert_eq!(
parse_key("pgup").unwrap(),
KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty())
);
assert_eq!(
parse_key("pgdown").unwrap(),
KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty())
);
assert_eq!(
parse_key("change").unwrap(),
KeyEvent::new(KeyCode::F(255), KeyModifiers::empty())
);
}
}
#[path = "binds_tests.rs"]
mod tests;

179
src/binds_tests.rs Normal file
View file

@ -0,0 +1,179 @@
use super::*;
use event::Action::*;
#[test]
fn test_parse_action_chain() {
let parsed = parse_action_chain(
"execute-silent:1 {}+execute-silent:2 {+}+execute-silent:3 {+n}+reload+if-query-empty:reload+up",
);
assert!(parsed.is_ok());
let res = parsed.unwrap();
assert_eq!(
res,
vec![
ExecuteSilent("1 {}".into()),
ExecuteSilent("2 {+}".into()),
ExecuteSilent("3 {+n}".into()),
Reload(None),
IfQueryEmpty("reload".into(), Some("up".into())),
]
);
}
#[test]
fn test_parse_key() {
assert_eq!(
parse_key("a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
);
assert_eq!(
parse_key("A").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("alt-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)
);
assert_eq!(
parse_key("alt-A").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("alt-shift-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("ctrl-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
);
assert_eq!(
parse_key("ctrl-A").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("ctrl-shift-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
assert_eq!(
parse_key("f10").unwrap(),
KeyEvent::new(KeyCode::F(10), KeyModifiers::empty())
);
assert_eq!(
parse_key("space").unwrap(),
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty())
);
assert_eq!(
parse_key("enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
);
assert_eq!(
parse_key("bspace").unwrap(),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())
);
assert_eq!(
parse_key("bs").unwrap(),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())
);
assert_eq!(
parse_key("up").unwrap(),
KeyEvent::new(KeyCode::Up, KeyModifiers::empty())
);
assert_eq!(
parse_key("down").unwrap(),
KeyEvent::new(KeyCode::Down, KeyModifiers::empty())
);
assert_eq!(
parse_key("left").unwrap(),
KeyEvent::new(KeyCode::Left, KeyModifiers::empty())
);
assert_eq!(
parse_key("right").unwrap(),
KeyEvent::new(KeyCode::Right, KeyModifiers::empty())
);
assert_eq!(
parse_key("tab").unwrap(),
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())
);
assert_eq!(
parse_key("btab").unwrap(),
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty())
);
assert_eq!(
parse_key("esc").unwrap(),
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
);
assert_eq!(
parse_key("home").unwrap(),
KeyEvent::new(KeyCode::Home, KeyModifiers::empty())
);
assert_eq!(
parse_key("end").unwrap(),
KeyEvent::new(KeyCode::End, KeyModifiers::empty())
);
assert_eq!(
parse_key("pgup").unwrap(),
KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty())
);
assert_eq!(
parse_key("pgdown").unwrap(),
KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty())
);
assert_eq!(
parse_key("change").unwrap(),
KeyEvent::new(KeyCode::F(255), KeyModifiers::empty())
);
}
#[test]
fn parse_key_error_cases() {
// Empty input.
assert!(parse_key("").is_err());
// Unknown modifier.
assert!(parse_key("hyper-a").is_err());
// Unknown key name.
assert!(parse_key("notakey").is_err());
// Invalid function-key index.
assert!(parse_key("fXY").is_err());
}
#[test]
fn keymap_from_str_parses_bindings() {
let keymap = KeyMap::from("ctrl-a:abort,enter:accept");
// Both keys resolve to action chains.
assert!(keymap.get(&parse_key("ctrl-a").unwrap()).is_some());
assert!(keymap.get(&parse_key("enter").unwrap()).is_some());
}
#[test]
fn parse_keymaps_collects_iterator() {
let keymap = parse_keymaps(["ctrl-x:abort", "up:up"].into_iter());
assert!(keymap.get(&parse_key("ctrl-x").unwrap()).is_some());
}
#[test]
fn parse_action_chain_unknown_is_error() {
assert!(parse_action_chain("not-a-real-action").is_err());
}
#[test]
fn parse_action_chain_accept_execute_reload_with_args() {
// `accept:hello`, `execute(...)` and `reload(...)` parse to the expected actions.
assert_eq!(
parse_action_chain("accept:hello").unwrap(),
vec![Accept(Some("hello".into()))]
);
assert_eq!(
parse_action_chain("execute(echo foo)").unwrap(),
vec![Execute("echo foo".into())]
);
assert_eq!(
parse_action_chain("reload(echo hello)").unwrap(),
vec![Reload(Some("echo hello".into()))]
);
assert_eq!(parse_action_chain("reload").unwrap(), vec![Reload(None)]);
}

View file

@ -42,3 +42,30 @@ impl Display for MatchAllEngine {
write!(f, "Noop")
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn matches_every_item_with_empty_range() {
let engine = MatchAllEngine::builder().build();
let result = engine.match_item(&"anything".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
}
#[test]
fn rank_builder_override_is_used() {
let engine = MatchAllEngine::builder()
.rank_builder(Arc::new(RankBuilder::default()))
.build();
assert!(engine.match_item(&"x".to_string()).is_some());
}
#[test]
fn display_is_noop() {
let engine = MatchAllEngine::builder().build();
assert_eq!(format!("{engine}"), "Noop");
}
}

View file

@ -138,3 +138,7 @@ impl Display for AndEngine {
)
}
}
#[cfg(test)]
#[path = "andor_tests.rs"]
mod tests;

101
src/engine/andor_tests.rs Normal file
View file

@ -0,0 +1,101 @@
use super::*;
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
fn exact(query: &str) -> Box<dyn MatchEngine> {
Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
}
#[test]
fn or_engine_matches_if_any_subengine_matches() {
let engine = OrEngine::builder().engines(vec![exact("foo"), exact("zzz")]).build();
assert!(engine.match_item(&"a foo bar".to_string()).is_some());
}
#[test]
fn or_engine_returns_none_when_no_subengine_matches() {
let engine = OrEngine::builder().engines(vec![exact("xxx"), exact("zzz")]).build();
assert!(engine.match_item(&"a foo bar".to_string()).is_none());
}
#[test]
fn or_engine_empty_returns_none() {
let engine = OrEngine::builder().build();
assert!(engine.match_item(&"anything".to_string()).is_none());
}
#[test]
fn and_engine_single_engine_fast_path() {
let engine = AndEngine::builder().engines(vec![exact("foo")]).build();
assert!(engine.match_item(&"foobar".to_string()).is_some());
assert!(engine.match_item(&"nope".to_string()).is_none());
}
#[test]
fn and_engine_requires_all_subengines_to_match() {
let engine = AndEngine::builder().engines(vec![exact("foo"), exact("bar")]).build();
// Both substrings present -> matched, ranges merged.
let result = engine.match_item(&"foo and bar".to_string());
assert!(result.is_some());
let result = result.unwrap();
assert!(matches!(result.matched_range, MatchRange::Chars(_)));
// Missing one substring -> no match.
assert!(engine.match_item(&"foo only".to_string()).is_none());
}
#[test]
fn and_engine_empty_returns_none() {
// With no sub-engines the multi-engine path collects nothing and bails.
let engine = AndEngine::builder().build();
assert!(engine.match_item(&"anything".to_string()).is_none());
}
#[test]
fn display_formats_combinators() {
let or = OrEngine::builder().engines(vec![exact("a")]).build();
assert!(format!("{or}").starts_with("(Or:"));
let and = AndEngine::builder().engines(vec![exact("a")]).build();
assert!(format!("{and}").starts_with("(And:"));
}
fn result(range: MatchRange, score: i32, begin: i32, end: i32) -> MatchResult {
MatchResult {
rank: crate::Rank {
score,
begin,
end,
..Default::default()
},
matched_range: range,
}
}
#[test]
fn merge_handles_char_range_and_chars_variants() {
// CharRange expands to its index span; Chars copies indices verbatim.
// Scores are summed and begin/end take the widest span.
let merged = AndEngine::merge_matched_items(
vec![
result(MatchRange::CharRange(0, 2), 5, 0, 2),
result(MatchRange::Chars(vec![4, 5]), 3, 4, 5),
],
"abcdef",
);
assert_eq!(merged.rank.score, 8);
assert_eq!(merged.rank.begin, 0);
assert_eq!(merged.rank.end, 5);
assert_eq!(merged.matched_range, MatchRange::Chars(vec![0, 1, 4, 5]));
}
#[test]
fn merge_dedups_and_sorts_overlapping_ranges() {
let merged = AndEngine::merge_matched_items(
vec![
result(MatchRange::Chars(vec![3, 1]), 1, 1, 3),
result(MatchRange::CharRange(1, 3), 1, 1, 3),
],
"abcdef",
);
// Sorted and de-duplicated union of {3,1} and {1,2}.
assert_eq!(merged.matched_range, MatchRange::Chars(vec![1, 2, 3]));
}

View file

@ -118,3 +118,7 @@ impl Display for ExactEngine {
)
}
}
#[cfg(test)]
#[path = "exact_tests.rs"]
mod tests;

113
src/engine/exact_tests.rs Normal file
View file

@ -0,0 +1,113 @@
use super::*;
fn engine(query: &str, param: ExactMatchingParam) -> ExactEngine {
ExactEngine::builder(query, param).build()
}
#[test]
fn case_respect_is_sensitive() {
let e = engine(
"Foo",
ExactMatchingParam {
case: CaseMatching::Respect,
..Default::default()
},
);
assert!(e.match_item(&"a Foo b".to_string()).is_some());
assert!(e.match_item(&"a foo b".to_string()).is_none());
}
#[test]
fn case_ignore_is_insensitive() {
let e = engine(
"Foo",
ExactMatchingParam {
case: CaseMatching::Ignore,
..Default::default()
},
);
assert!(e.match_item(&"a foo b".to_string()).is_some());
assert!(e.match_item(&"a FOO b".to_string()).is_some());
}
#[test]
fn case_smart_uppercase_query_is_sensitive() {
let e = engine(
"Foo",
ExactMatchingParam {
case: CaseMatching::Smart,
..Default::default()
},
);
assert!(e.match_item(&"Foo".to_string()).is_some());
assert!(e.match_item(&"foo".to_string()).is_none());
}
#[test]
fn prefix_and_postfix_anchors() {
let prefix = engine(
"foo",
ExactMatchingParam {
prefix: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
assert!(prefix.match_item(&"foobar".to_string()).is_some());
assert!(prefix.match_item(&"barfoo".to_string()).is_none());
let postfix = engine(
"foo",
ExactMatchingParam {
postfix: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
assert!(postfix.match_item(&"barfoo".to_string()).is_some());
assert!(postfix.match_item(&"foobar".to_string()).is_none());
}
#[test]
fn inverse_match_excludes_query() {
let e = engine(
"foo",
ExactMatchingParam {
inverse: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
// Inverse: items WITHOUT the query match.
assert!(e.match_item(&"bar".to_string()).is_some());
assert!(e.match_item(&"foo".to_string()).is_none());
}
#[test]
fn empty_query_matches_everything() {
let e = engine("", ExactMatchingParam::default());
let result = e.match_item(&"anything".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
}
#[test]
fn display_shows_query_and_inverse_marker() {
let plain = engine(
"foo",
ExactMatchingParam {
case: CaseMatching::Respect,
..Default::default()
},
);
assert_eq!(format!("{plain}"), "(Exact|foo)");
let inverse = engine(
"foo",
ExactMatchingParam {
inverse: true,
case: CaseMatching::Respect,
..Default::default()
},
);
assert!(format!("{inverse}").starts_with("(Exact|!"));
}

View file

@ -263,6 +263,7 @@ impl MatchEngineFactory for RegexEngineFactory {
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
#[test]
fn test_engine_factory() {
@ -310,4 +311,15 @@ mod test {
"(And: (Fuzzy: readme), (Or: (Exact|(?i)\\.md$), (Exact|(?i)\\.markdown$)))"
);
}
#[test]
fn regex_factory_with_rank_builder() {
use super::*;
// Exercise the `rank_builder` and `build` chaining on RegexEngineFactory.
let factory = RegexEngineFactory::builder()
.rank_builder(Arc::new(RankBuilder::default()))
.build();
let engine = factory.create_engine("ab.");
assert_eq!(format!("{engine}"), "(Regex: ab.)");
}
}

View file

@ -229,3 +229,68 @@ impl Display for FuzzyEngine {
write!(f, "(Fuzzy: {})", self.query)
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn effective_max_typos_per_variant() {
let disabled = FuzzyEngine::builder().query("hello").typos(Typos::Disabled);
assert_eq!(disabled.effective_max_typos(), None);
let smart = FuzzyEngine::builder().query("hello").typos(Typos::Smart);
assert_eq!(smart.effective_max_typos(), Some(1)); // 5 / 4 = 1
let fixed = FuzzyEngine::builder().query("hello").typos(Typos::Fixed(3));
assert_eq!(fixed.effective_max_typos(), Some(3));
}
/// Every algorithm × case combination should build and match a basic query.
#[test]
fn builds_every_algorithm_and_case() {
let algorithms = [
FuzzyAlgorithm::SkimV2,
FuzzyAlgorithm::Clangd,
FuzzyAlgorithm::Fzy,
FuzzyAlgorithm::Arinae,
];
let cases = [CaseMatching::Respect, CaseMatching::Ignore, CaseMatching::Smart];
for algo in algorithms {
for case in cases {
let engine = FuzzyEngine::builder().query("foo").algorithm(algo).case(case).build();
assert!(
engine.match_item(&"foobar".to_string()).is_some(),
"algo {algo:?} case {case:?} should match"
);
}
}
}
#[test]
fn empty_query_yields_empty_char_range() {
let engine = FuzzyEngine::builder().query("").build();
let result = engine.match_item(&"anything".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
}
#[test]
fn matching_query_yields_char_indices() {
let engine = FuzzyEngine::builder().query("fb").build();
let result = engine.match_item(&"foobar".to_string()).unwrap();
assert!(matches!(result.matched_range, MatchRange::Chars(_)));
}
#[test]
fn no_match_returns_none() {
let engine = FuzzyEngine::builder().query("zzz").build();
assert!(engine.match_item(&"foobar".to_string()).is_none());
}
#[test]
fn display_shows_query() {
let engine = FuzzyEngine::builder().query("foo").build();
assert_eq!(format!("{engine}"), "(Fuzzy: foo)");
}
}

View file

@ -102,3 +102,105 @@ impl MatchEngineFactory for NormalizedEngineFactory {
Box::new(NormalizedEngine::new(inner_engine))
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
use crate::prelude::ExactOrFuzzyEngineFactory;
#[test]
fn matches_through_diacritics() {
// Inner exact engine searches for the ASCII form; the normalized engine
// strips the accent from the item text before matching.
let inner = Box::new(ExactEngine::builder("cafe", ExactMatchingParam::default()).build());
let engine = NormalizedEngine::new(inner);
let result = engine.match_item(&"café".to_string());
assert!(result.is_some());
}
#[test]
fn no_match_returns_none() {
let inner = Box::new(ExactEngine::builder("zzz", ExactMatchingParam::default()).build());
let engine = NormalizedEngine::new(inner);
assert!(engine.match_item(&"café".to_string()).is_none());
}
#[test]
fn display_includes_inner_engine() {
let inner = Box::new(ExactEngine::builder("x", ExactMatchingParam::default()).build());
let engine = NormalizedEngine::new(inner);
assert!(format!("{engine}").starts_with("(Normalized:"));
}
#[test]
fn factory_creates_normalized_engine() {
let factory = NormalizedEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
let engine = factory.create_engine_with_case("cafe", CaseMatching::Smart);
// The accented item should match the normalized query.
assert!(engine.match_item(&"café".to_string()).is_some());
}
/// Inner engine that always returns a fixed `CharRange`, so the normalized
/// engine's `CharRange` remapping branch is exercised.
struct CharRangeStub(usize, usize);
impl MatchEngine for CharRangeStub {
fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
Some(MatchResult {
rank: crate::Rank::default(),
matched_range: MatchRange::CharRange(self.0, self.1),
})
}
}
impl Display for CharRangeStub {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
write!(f, "CharRangeStub")
}
}
#[test]
fn char_range_is_mapped_back_to_original() {
// café → cafe is a 1:1 normalization, so the char range is unchanged.
let engine = NormalizedEngine::new(Box::new(CharRangeStub(1, 3)));
let result = engine.match_item(&"café".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::CharRange(1, 3));
}
#[test]
fn empty_char_range_maps_to_zero() {
// An empty range (end == 0) maps straight back to (0, 0).
let engine = NormalizedEngine::new(Box::new(CharRangeStub(0, 0)));
let result = engine.match_item(&"café".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
}
/// Inner engine returning fixed `Chars` indices, exercising the
/// `map_char_indices_to_original` remapping branch.
struct CharsStub(Vec<usize>);
impl MatchEngine for CharsStub {
fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
Some(MatchResult {
rank: crate::Rank::default(),
matched_range: MatchRange::Chars(self.0.clone()),
})
}
}
impl Display for CharsStub {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
write!(f, "CharsStub")
}
}
#[test]
fn chars_indices_are_mapped_back_to_original() {
// 1:1 normalization (café → cafe) keeps the char indices unchanged.
let engine = NormalizedEngine::new(Box::new(CharsStub(vec![0, 2])));
let result = engine.match_item(&"café".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 2]));
}
}

View file

@ -85,3 +85,61 @@ impl Display for RegexEngine {
)
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
fn engine(query: &str, case: CaseMatching) -> RegexEngine {
RegexEngine::builder(query, case).build()
}
#[test]
fn matches_regex_pattern() {
let e = engine("ba.", CaseMatching::Respect);
let result = e.match_item(&"foobar".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::ByteRange(3, 6));
}
#[test]
fn no_match_returns_none() {
let e = engine("xyz", CaseMatching::Respect);
assert!(e.match_item(&"foobar".to_string()).is_none());
}
#[test]
fn ignore_case_matches_insensitively() {
let e = engine("foo", CaseMatching::Ignore);
assert!(e.match_item(&"FOOBAR".to_string()).is_some());
}
#[test]
fn smart_case_is_sensitive() {
let e = engine("foo", CaseMatching::Smart);
assert!(e.match_item(&"foobar".to_string()).is_some());
assert!(e.match_item(&"FOOBAR".to_string()).is_none());
}
#[test]
fn empty_query_matches_everything() {
// An empty pattern produces a regex that matches at position 0.
let e = engine("", CaseMatching::Respect);
assert!(e.match_item(&"anything".to_string()).is_some());
}
#[test]
fn invalid_regex_yields_no_regex_and_matches_all() {
// An unparsable pattern leaves `query_regex` as None, which short-circuits
// to a zero-length match for every item.
let e = engine("(", CaseMatching::Respect);
let result = e.match_item(&"abc".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
}
#[test]
fn display_shows_pattern() {
let e = engine("ba.", CaseMatching::Respect);
assert_eq!(format!("{e}"), "(Regex: ba.)");
}
}

View file

@ -150,3 +150,88 @@ impl MatchEngineFactory for SplitMatchEngineFactory {
}
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
use crate::prelude::ExactOrFuzzyEngineFactory;
/// A stub engine that returns a fixed match range, regardless of the item.
struct StubEngine(MatchRange);
impl MatchEngine for StubEngine {
fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
Some(MatchResult {
rank: crate::Rank::default(),
matched_range: self.0.clone(),
})
}
}
impl Display for StubEngine {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
write!(f, "Stub")
}
}
fn exact(query: &str) -> Box<dyn MatchEngine> {
Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
}
#[test]
fn no_delimiter_in_item_returns_none() {
let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
assert!(engine.match_item(&"no delimiter here".to_string()).is_none());
}
#[test]
fn matches_both_sides_of_delimiter() {
let engine = SplitMatchEngine::new(exact("ab"), exact("cd"), ':');
let result = engine.match_item(&"ab:cd".to_string());
assert!(result.is_some());
}
#[test]
fn char_range_results_are_offset_and_combined() {
// Before matches chars 0..2 of "ab"; after matches chars 0..2 of "cd"
// which become 3..5 after the delimiter offset.
let engine = SplitMatchEngine::new(
Box::new(StubEngine(MatchRange::CharRange(0, 2))),
Box::new(StubEngine(MatchRange::CharRange(0, 2))),
':',
);
let result = engine.match_item(&"ab:cd".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
}
#[test]
fn after_engine_failure_returns_none() {
let engine = SplitMatchEngine::new(exact("ab"), exact("zzz"), ':');
assert!(engine.match_item(&"ab:cd".to_string()).is_none());
}
#[test]
fn display_shows_both_engines_and_delimiter() {
let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
let s = format!("{engine}");
assert!(s.starts_with("(Split[:]:"));
}
#[test]
fn factory_without_delimiter_passes_through() {
let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
let engine = factory.create_engine_with_case("foo", crate::CaseMatching::Smart);
// Plain query, no delimiter → behaves like the inner engine.
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn factory_with_delimiter_builds_split_engine() {
let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
let engine = factory.create_engine_with_case("ab:cd", crate::CaseMatching::Smart);
assert!(engine.match_item(&"ab:cd".to_string()).is_some());
assert!(engine.match_item(&"ab:xy".to_string()).is_none());
}
}

View file

@ -107,3 +107,50 @@ pub fn contains_upper(string: &str) -> bool {
}
false
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn contains_upper_detects_case() {
assert!(contains_upper("heLlo"));
assert!(contains_upper("ABC"));
assert!(!contains_upper("hello"));
assert!(!contains_upper("123 -_"));
}
#[test]
fn regex_match_finds_and_misses() {
let re = Regex::new("ba.").unwrap();
assert_eq!(regex_match("foobar", Some(&re)), Some((3, 6)));
assert_eq!(regex_match("nope", Some(&re)), None);
assert_eq!(regex_match("foobar", None), None);
}
#[test]
fn byte_range_empty_mapping_returns_zero() {
// Empty mapping or out-of-range start short-circuits to (0, 0).
assert_eq!(map_byte_range_to_original(0, 2, &[], "abc"), (0, 0));
assert_eq!(map_byte_range_to_original(5, 6, &[0, 1, 2], "abc"), (0, 0));
}
#[test]
fn byte_range_end_past_mapping_uses_string_len() {
// normalized_end beyond the mapping length clamps to the original length.
assert_eq!(map_byte_range_to_original(0, 5, &[0, 1, 2], "abc"), (0, 3));
}
#[test]
fn byte_range_zero_end_falls_back_to_start() {
// normalized_end == 0 yields an empty range anchored at orig_start.
assert_eq!(map_byte_range_to_original(1, 0, &[0, 1, 2], "abc"), (1, 1));
}
#[test]
fn byte_range_maps_normal_range() {
// A normal in-bounds range maps to the byte span of the original char.
assert_eq!(map_byte_range_to_original(0, 2, &[0, 1, 2], "abc"), (0, 2));
}
}

View file

@ -182,238 +182,5 @@ pub fn parse_transform_fields(delimiter: &Regex, text: &str, fields: &[FieldRang
}
#[cfg(test)]
mod test {
use super::FieldRange::*;
#[test]
fn test_parse_range() {
assert_eq!(FieldRange::from_str("1"), Some(Single(1)));
assert_eq!(FieldRange::from_str("-1"), Some(Single(-1)));
assert_eq!(FieldRange::from_str("1.."), Some(RightInf(1)));
assert_eq!(FieldRange::from_str("-1.."), Some(RightInf(-1)));
assert_eq!(FieldRange::from_str("..1"), Some(LeftInf(1)));
assert_eq!(FieldRange::from_str("..-1"), Some(LeftInf(-1)));
assert_eq!(FieldRange::from_str("1..3"), Some(Both(1, 3)));
assert_eq!(FieldRange::from_str("-1..-3"), Some(Both(-1, -3)));
assert_eq!(FieldRange::from_str(".."), Some(RightInf(0)));
assert_eq!(FieldRange::from_str("a.."), None);
assert_eq!(FieldRange::from_str("..b"), None);
assert_eq!(FieldRange::from_str("a..b"), None);
}
use regex::Regex;
#[test]
fn test_parse_field_range() {
assert_eq!(Single(0).to_index_pair(10), None);
assert_eq!(Single(1).to_index_pair(10), Some((0, 1)));
assert_eq!(Single(10).to_index_pair(10), Some((9, 10)));
assert_eq!(Single(11).to_index_pair(10), None);
assert_eq!(Single(-1).to_index_pair(10), Some((9, 10)));
assert_eq!(Single(-10).to_index_pair(10), Some((0, 1)));
assert_eq!(Single(-11).to_index_pair(10), None);
assert_eq!(LeftInf(0).to_index_pair(10), None);
assert_eq!(LeftInf(1).to_index_pair(10), Some((0, 1)));
assert_eq!(LeftInf(8).to_index_pair(10), Some((0, 8)));
assert_eq!(LeftInf(10).to_index_pair(10), Some((0, 10)));
assert_eq!(LeftInf(11).to_index_pair(10), Some((0, 10)));
assert_eq!(LeftInf(-1).to_index_pair(10), Some((0, 10)));
assert_eq!(LeftInf(-8).to_index_pair(10), Some((0, 3)));
assert_eq!(LeftInf(-9).to_index_pair(10), Some((0, 2)));
assert_eq!(LeftInf(-10).to_index_pair(10), Some((0, 1)));
assert_eq!(LeftInf(-11).to_index_pair(10), None);
assert_eq!(RightInf(0).to_index_pair(10), Some((0, 10)));
assert_eq!(RightInf(1).to_index_pair(10), Some((0, 10)));
assert_eq!(RightInf(8).to_index_pair(10), Some((7, 10)));
assert_eq!(RightInf(10).to_index_pair(10), Some((9, 10)));
assert_eq!(RightInf(11).to_index_pair(10), None);
assert_eq!(RightInf(-1).to_index_pair(10), Some((9, 10)));
assert_eq!(RightInf(-8).to_index_pair(10), Some((2, 10)));
assert_eq!(RightInf(-9).to_index_pair(10), Some((1, 10)));
assert_eq!(RightInf(-10).to_index_pair(10), Some((0, 10)));
assert_eq!(RightInf(-11).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(0, 0).to_index_pair(10), None);
assert_eq!(Both(0, 1).to_index_pair(10), Some((0, 1)));
assert_eq!(Both(0, 10).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(0, 11).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(1, -1).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(1, -9).to_index_pair(10), Some((0, 2)));
assert_eq!(Both(1, -10).to_index_pair(10), Some((0, 1)));
assert_eq!(Both(1, -11).to_index_pair(10), None);
assert_eq!(Both(-9, -9).to_index_pair(10), Some((1, 2)));
assert_eq!(Both(-9, -8).to_index_pair(10), Some((1, 3)));
assert_eq!(Both(-9, 0).to_index_pair(10), None);
assert_eq!(Both(-9, 1).to_index_pair(10), None);
assert_eq!(Both(-9, 2).to_index_pair(10), Some((1, 2)));
assert_eq!(Both(-1, 0).to_index_pair(10), None);
assert_eq!(Both(11, 20).to_index_pair(10), None);
assert_eq!(Both(-11, -11).to_index_pair(10), None);
}
#[test]
fn test_parse_transform_fields() {
// delimiter is ","
let re = Regex::new(",").unwrap();
assert_eq!(
super::parse_transform_fields(&re, "A,B,C,D,E,F", &[Single(2), Single(4), Single(-1), Single(-7)]),
"B,D,F"
);
assert_eq!(
super::parse_transform_fields(&re, "A,B,C,D,E,F", &[LeftInf(3), LeftInf(-6), LeftInf(-7)]),
"A,B,C,A,"
);
assert_eq!(
super::parse_transform_fields(
&re,
"A,B,C,D,E,F",
&[RightInf(5), RightInf(-2), RightInf(-1), RightInf(8)]
),
"E,FE,FF"
);
assert_eq!(
super::parse_transform_fields(
&re,
"A,B,C,D,E,F",
&[Both(3, 3), Both(-9, 2), Both(6, 10), Both(-9, -5)]
),
"C,A,B,FA,B,"
);
}
#[test]
fn test_parse_matching_fields() {
// delimiter is ","
let re = Regex::new(",").unwrap();
// bytes:3 3 3 3
// 中,华,人,民,E,F",
assert_eq!(
super::parse_matching_fields(&re, "中,华,人,民,E,F", &[Single(2), Single(4), Single(-1), Single(-7)]),
vec![(4, 8), (12, 16), (18, 19)]
);
assert_eq!(
super::parse_matching_fields(&re, "中,华,人,民,E,F", &[LeftInf(3), LeftInf(-6), LeftInf(-7)]),
vec![(0, 12), (0, 4)]
);
assert_eq!(
super::parse_matching_fields(
&re,
"中,华,人,民,E,F",
&[RightInf(5), RightInf(-2), RightInf(-1), RightInf(7)]
),
vec![(16, 19), (16, 19), (18, 19)]
);
assert_eq!(
super::parse_matching_fields(
&re,
"中,华,人,民,E,F",
&[Both(3, 3), Both(-8, 2), Both(6, 10), Both(-8, -5)]
),
vec![(8, 12), (0, 8), (18, 19), (0, 8)]
);
}
use super::*;
#[test]
fn test_null_delimiter() {
// Test with null byte delimiter
let re = Regex::new("\x00").unwrap();
let text = "a\x00b\x00c";
// Test field extraction
assert_eq!(get_string_by_field(&re, text, &Single(1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Single(2)), Some("b"));
assert_eq!(get_string_by_field(&re, text, &Single(3)), Some("c"));
// Test matching fields - ranges include the delimiter after the field
// text bytes: a(0), \0(1), b(2), \0(3), c(4)
// Field 2 is "b" at byte 2, range includes delimiter at byte 3, so (2, 4)
assert_eq!(parse_matching_fields(&re, text, &[Single(2)]), vec![(2, 4)]);
// Field 1 is "a" at byte 0, range includes delimiter at byte 1, so (0, 2)
// Field 3 is "c" at byte 4, no delimiter after it, so (4, 5)
assert_eq!(
parse_matching_fields(&re, text, &[Single(1), Single(3)]),
vec![(0, 2), (4, 5)]
);
}
#[test]
fn test_get_string_by_field() {
// delimiter is ","
let re = Regex::new(",").unwrap();
let text = "a,b,c,";
assert_eq!(get_string_by_field(&re, text, &Single(0)), None);
assert_eq!(get_string_by_field(&re, text, &Single(1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Single(2)), Some("b"));
assert_eq!(get_string_by_field(&re, text, &Single(3)), Some("c"));
assert_eq!(get_string_by_field(&re, text, &Single(4)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Single(5)), None);
assert_eq!(get_string_by_field(&re, text, &Single(6)), None);
assert_eq!(get_string_by_field(&re, text, &Single(-1)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Single(-2)), Some("c"));
assert_eq!(get_string_by_field(&re, text, &Single(-3)), Some("b"));
assert_eq!(get_string_by_field(&re, text, &Single(-4)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Single(-5)), None);
assert_eq!(get_string_by_field(&re, text, &Single(-6)), None);
assert_eq!(get_string_by_field(&re, text, &LeftInf(0)), None);
assert_eq!(get_string_by_field(&re, text, &LeftInf(1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(2)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(3)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-5)), None);
assert_eq!(get_string_by_field(&re, text, &LeftInf(-4)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-3)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-2)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-1)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(0)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(1)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(2)), Some("b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(3)), Some("c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(4)), Some(""));
assert_eq!(get_string_by_field(&re, text, &RightInf(5)), None);
assert_eq!(get_string_by_field(&re, text, &RightInf(-5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-3)), Some("b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-2)), Some("c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-1)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Both(0, 0)), None);
assert_eq!(get_string_by_field(&re, text, &Both(0, 1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 2)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 3)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 2)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 3)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(2, 5)), Some("b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(3, 5)), Some("c,"));
assert_eq!(get_string_by_field(&re, text, &Both(4, 5)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Both(5, 5)), None);
assert_eq!(get_string_by_field(&re, text, &Both(6, 5)), None);
assert_eq!(get_string_by_field(&re, text, &Both(2, 3)), Some("b,c"));
assert_eq!(get_string_by_field(&re, text, &Both(3, 3)), Some("c"));
assert_eq!(get_string_by_field(&re, text, &Both(4, 3)), None);
}
}
#[path = "field_tests.rs"]
mod test;

255
src/field_tests.rs Normal file
View file

@ -0,0 +1,255 @@
use super::FieldRange::*;
#[test]
fn test_parse_range() {
assert_eq!(FieldRange::from_str("1"), Some(Single(1)));
assert_eq!(FieldRange::from_str("-1"), Some(Single(-1)));
assert_eq!(FieldRange::from_str("1.."), Some(RightInf(1)));
assert_eq!(FieldRange::from_str("-1.."), Some(RightInf(-1)));
assert_eq!(FieldRange::from_str("..1"), Some(LeftInf(1)));
assert_eq!(FieldRange::from_str("..-1"), Some(LeftInf(-1)));
assert_eq!(FieldRange::from_str("1..3"), Some(Both(1, 3)));
assert_eq!(FieldRange::from_str("-1..-3"), Some(Both(-1, -3)));
assert_eq!(FieldRange::from_str(".."), Some(RightInf(0)));
assert_eq!(FieldRange::from_str("a.."), None);
assert_eq!(FieldRange::from_str("..b"), None);
assert_eq!(FieldRange::from_str("a..b"), None);
}
use regex::Regex;
#[test]
fn test_parse_field_range() {
assert_eq!(Single(0).to_index_pair(10), None);
assert_eq!(Single(1).to_index_pair(10), Some((0, 1)));
assert_eq!(Single(10).to_index_pair(10), Some((9, 10)));
assert_eq!(Single(11).to_index_pair(10), None);
assert_eq!(Single(-1).to_index_pair(10), Some((9, 10)));
assert_eq!(Single(-10).to_index_pair(10), Some((0, 1)));
assert_eq!(Single(-11).to_index_pair(10), None);
assert_eq!(LeftInf(0).to_index_pair(10), None);
assert_eq!(LeftInf(1).to_index_pair(10), Some((0, 1)));
assert_eq!(LeftInf(8).to_index_pair(10), Some((0, 8)));
assert_eq!(LeftInf(10).to_index_pair(10), Some((0, 10)));
assert_eq!(LeftInf(11).to_index_pair(10), Some((0, 10)));
assert_eq!(LeftInf(-1).to_index_pair(10), Some((0, 10)));
assert_eq!(LeftInf(-8).to_index_pair(10), Some((0, 3)));
assert_eq!(LeftInf(-9).to_index_pair(10), Some((0, 2)));
assert_eq!(LeftInf(-10).to_index_pair(10), Some((0, 1)));
assert_eq!(LeftInf(-11).to_index_pair(10), None);
assert_eq!(RightInf(0).to_index_pair(10), Some((0, 10)));
assert_eq!(RightInf(1).to_index_pair(10), Some((0, 10)));
assert_eq!(RightInf(8).to_index_pair(10), Some((7, 10)));
assert_eq!(RightInf(10).to_index_pair(10), Some((9, 10)));
assert_eq!(RightInf(11).to_index_pair(10), None);
assert_eq!(RightInf(-1).to_index_pair(10), Some((9, 10)));
assert_eq!(RightInf(-8).to_index_pair(10), Some((2, 10)));
assert_eq!(RightInf(-9).to_index_pair(10), Some((1, 10)));
assert_eq!(RightInf(-10).to_index_pair(10), Some((0, 10)));
assert_eq!(RightInf(-11).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(0, 0).to_index_pair(10), None);
assert_eq!(Both(0, 1).to_index_pair(10), Some((0, 1)));
assert_eq!(Both(0, 10).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(0, 11).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(1, -1).to_index_pair(10), Some((0, 10)));
assert_eq!(Both(1, -9).to_index_pair(10), Some((0, 2)));
assert_eq!(Both(1, -10).to_index_pair(10), Some((0, 1)));
assert_eq!(Both(1, -11).to_index_pair(10), None);
assert_eq!(Both(-9, -9).to_index_pair(10), Some((1, 2)));
assert_eq!(Both(-9, -8).to_index_pair(10), Some((1, 3)));
assert_eq!(Both(-9, 0).to_index_pair(10), None);
assert_eq!(Both(-9, 1).to_index_pair(10), None);
assert_eq!(Both(-9, 2).to_index_pair(10), Some((1, 2)));
assert_eq!(Both(-1, 0).to_index_pair(10), None);
assert_eq!(Both(11, 20).to_index_pair(10), None);
assert_eq!(Both(-11, -11).to_index_pair(10), None);
}
#[test]
fn test_to_index_pair_zero_length() {
// With no fields at all, every range variant must yield `None` rather than
// panicking or producing an out-of-bounds pair (the `length == 0` guards).
assert_eq!(Single(1).to_index_pair(0), None);
assert_eq!(LeftInf(1).to_index_pair(0), None);
assert_eq!(RightInf(1).to_index_pair(0), None);
assert_eq!(Both(1, 2).to_index_pair(0), None);
}
#[test]
fn test_parse_transform_fields() {
// delimiter is ","
let re = Regex::new(",").unwrap();
assert_eq!(
super::parse_transform_fields(&re, "A,B,C,D,E,F", &[Single(2), Single(4), Single(-1), Single(-7)]),
"B,D,F"
);
assert_eq!(
super::parse_transform_fields(&re, "A,B,C,D,E,F", &[LeftInf(3), LeftInf(-6), LeftInf(-7)]),
"A,B,C,A,"
);
assert_eq!(
super::parse_transform_fields(
&re,
"A,B,C,D,E,F",
&[RightInf(5), RightInf(-2), RightInf(-1), RightInf(8)]
),
"E,FE,FF"
);
assert_eq!(
super::parse_transform_fields(
&re,
"A,B,C,D,E,F",
&[Both(3, 3), Both(-9, 2), Both(6, 10), Both(-9, -5)]
),
"C,A,B,FA,B,"
);
}
#[test]
fn test_parse_matching_fields() {
// delimiter is ","
let re = Regex::new(",").unwrap();
// bytes:3 3 3 3
// 中,华,人,民,E,F",
assert_eq!(
super::parse_matching_fields(&re, "中,华,人,民,E,F", &[Single(2), Single(4), Single(-1), Single(-7)]),
vec![(4, 8), (12, 16), (18, 19)]
);
assert_eq!(
super::parse_matching_fields(&re, "中,华,人,民,E,F", &[LeftInf(3), LeftInf(-6), LeftInf(-7)]),
vec![(0, 12), (0, 4)]
);
assert_eq!(
super::parse_matching_fields(
&re,
"中,华,人,民,E,F",
&[RightInf(5), RightInf(-2), RightInf(-1), RightInf(7)]
),
vec![(16, 19), (16, 19), (18, 19)]
);
assert_eq!(
super::parse_matching_fields(
&re,
"中,华,人,民,E,F",
&[Both(3, 3), Both(-8, 2), Both(6, 10), Both(-8, -5)]
),
vec![(8, 12), (0, 8), (18, 19), (0, 8)]
);
}
use super::*;
#[test]
fn test_null_delimiter() {
// Test with null byte delimiter
let re = Regex::new("\x00").unwrap();
let text = "a\x00b\x00c";
// Test field extraction
assert_eq!(get_string_by_field(&re, text, &Single(1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Single(2)), Some("b"));
assert_eq!(get_string_by_field(&re, text, &Single(3)), Some("c"));
// Test matching fields - ranges include the delimiter after the field
// text bytes: a(0), \0(1), b(2), \0(3), c(4)
// Field 2 is "b" at byte 2, range includes delimiter at byte 3, so (2, 4)
assert_eq!(parse_matching_fields(&re, text, &[Single(2)]), vec![(2, 4)]);
// Field 1 is "a" at byte 0, range includes delimiter at byte 1, so (0, 2)
// Field 3 is "c" at byte 4, no delimiter after it, so (4, 5)
assert_eq!(
parse_matching_fields(&re, text, &[Single(1), Single(3)]),
vec![(0, 2), (4, 5)]
);
}
#[test]
fn test_get_string_by_field() {
// delimiter is ","
let re = Regex::new(",").unwrap();
let text = "a,b,c,";
assert_eq!(get_string_by_field(&re, text, &Single(0)), None);
assert_eq!(get_string_by_field(&re, text, &Single(1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Single(2)), Some("b"));
assert_eq!(get_string_by_field(&re, text, &Single(3)), Some("c"));
assert_eq!(get_string_by_field(&re, text, &Single(4)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Single(5)), None);
assert_eq!(get_string_by_field(&re, text, &Single(6)), None);
assert_eq!(get_string_by_field(&re, text, &Single(-1)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Single(-2)), Some("c"));
assert_eq!(get_string_by_field(&re, text, &Single(-3)), Some("b"));
assert_eq!(get_string_by_field(&re, text, &Single(-4)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Single(-5)), None);
assert_eq!(get_string_by_field(&re, text, &Single(-6)), None);
assert_eq!(get_string_by_field(&re, text, &LeftInf(0)), None);
assert_eq!(get_string_by_field(&re, text, &LeftInf(1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(2)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(3)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-5)), None);
assert_eq!(get_string_by_field(&re, text, &LeftInf(-4)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-3)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-2)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &LeftInf(-1)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(0)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(1)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(2)), Some("b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(3)), Some("c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(4)), Some(""));
assert_eq!(get_string_by_field(&re, text, &RightInf(5)), None);
assert_eq!(get_string_by_field(&re, text, &RightInf(-5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-3)), Some("b,c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-2)), Some("c,"));
assert_eq!(get_string_by_field(&re, text, &RightInf(-1)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Both(0, 0)), None);
assert_eq!(get_string_by_field(&re, text, &Both(0, 1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 2)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 3)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(0, 5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 1)), Some("a"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 2)), Some("a,b"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 3)), Some("a,b,c"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 4)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(1, 5)), Some("a,b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(2, 5)), Some("b,c,"));
assert_eq!(get_string_by_field(&re, text, &Both(3, 5)), Some("c,"));
assert_eq!(get_string_by_field(&re, text, &Both(4, 5)), Some(""));
assert_eq!(get_string_by_field(&re, text, &Both(5, 5)), None);
assert_eq!(get_string_by_field(&re, text, &Both(6, 5)), None);
assert_eq!(get_string_by_field(&re, text, &Both(2, 3)), Some("b,c"));
assert_eq!(get_string_by_field(&re, text, &Both(3, 3)), Some("c"));
assert_eq!(get_string_by_field(&re, text, &Both(4, 3)), None);
}
#[test]
fn test_get_string_by_range() {
let re = Regex::new(",").unwrap();
let text = "a,b,c";
// Parses the range string then extracts the matching field(s).
assert_eq!(get_string_by_range(&re, text, "1"), Some("a"));
assert_eq!(get_string_by_range(&re, text, "2.."), Some("b,c"));
assert_eq!(get_string_by_range(&re, text, "..2"), Some("a,b"));
// An unparsable range yields None.
assert_eq!(get_string_by_range(&re, text, "not-a-range"), None);
}

View file

@ -120,3 +120,70 @@ impl Atom for char {
self.is_lowercase()
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn u8_find_first_case_sensitive() {
let hay = b"abcABC";
// Case-sensitive uses memchr directly.
assert_eq!(b'A'.find_first_in(hay, true), Some(3));
assert_eq!(b'z'.find_first_in(hay, true), None);
}
#[test]
fn u8_find_first_case_insensitive_letter() {
let hay = b"xxABCxx";
// Case-insensitive letter checks both variants and returns the earliest.
assert_eq!(b'a'.find_first_in(hay, false), Some(2));
assert_eq!(b'C'.find_first_in(hay, false), Some(4));
}
#[test]
fn u8_find_first_case_insensitive_digit() {
let hay = b"a1b2";
// Digits have no case distinction → single memchr branch.
assert_eq!(b'2'.find_first_in(hay, false), Some(3));
assert_eq!(b'9'.find_first_in(hay, false), None);
}
#[test]
fn u8_find_first_only_one_case_present() {
let hay = b"hello"; // only lowercase present
// Uppercase query, only lowercase in haystack → (Some, None) arm.
assert_eq!(b'L'.find_first_in(hay, false), Some(2));
}
#[test]
fn u8_find_last_case_variants() {
let hay = b"aAbA";
// Case-sensitive backward search.
assert_eq!(b'A'.find_last_in(hay, true), Some(3));
// Case-insensitive returns the rightmost across both variants.
assert_eq!(b'a'.find_last_in(hay, false), Some(3));
}
#[test]
fn u8_find_last_case_insensitive_digit_and_single_case() {
let hay = b"1a1";
// Digit: no case distinction.
assert_eq!(b'1'.find_last_in(hay, false), Some(2));
// Only lowercase present, uppercase query → (None, Some)/(Some, None) arm.
assert_eq!(b'A'.find_last_in(hay, false), Some(1));
}
#[test]
fn char_atom_eq_and_case() {
assert!('a'.eq('A', false));
assert!(!'a'.eq('A', true));
assert!('a'.is_lowercase());
assert!(!'A'.is_lowercase());
// Default (non-SIMD) find impls for char.
let hay: Vec<char> = "abAB".chars().collect();
assert_eq!('A'.find_first_in(&hay, true), Some(2));
assert_eq!('a'.find_last_in(&hay, false), Some(2));
}
}

View file

@ -81,12 +81,10 @@ fn compute_first_match_cols<C: Atom>(pat: &[C], cho: &[C], respect_case: bool) -
let mut start = 0usize; // search from this choice index onward
for i in 0..n {
let found = cho[start..].iter().position(|&c| pat[i].eq(c, respect_case));
match found {
Some(pos) => {
first[i] = start + pos + 1; // 1-indexed column
start = start + pos + 1; // next char must be strictly after
}
None => return None,
{
let pos = found?;
first[i] = start + pos + 1; // 1-indexed column
start = start + pos + 1; // next char must be strictly after
}
}
Some(first)

View file

@ -31,12 +31,10 @@ pub(super) fn compute_last_match_cols<C: Atom>(
let mut end = m; // search up to this choice index (exclusive)
for i in (0..n).rev() {
let found = pat[i].find_last_in(&cho[..end], respect_case);
match found {
Some(pos) => {
last[i] = pos + 1; // 1-indexed column
end = pos; // previous char must be strictly before
}
None => return None,
{
let pos = found?;
last[i] = pos + 1; // 1-indexed column
end = pos; // previous char must be strictly before
}
}
Some(last)

View file

@ -88,3 +88,56 @@ impl SWMatrix {
self.cols = cols;
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn cell_packs_score_and_direction() {
let cell = Cell::new(42, Dir::Diag);
assert_eq!(cell.score(), 42);
assert_eq!(cell.dir(), Dir::Diag);
assert!(cell.is_diag());
// Negative scores round-trip through the i16 bitcast.
let neg = Cell::new(-7, Dir::Up);
assert_eq!(neg.score(), -7);
assert_eq!(neg.dir(), Dir::Up);
assert!(!neg.is_diag());
}
#[test]
fn cell_zero_is_none_direction() {
assert_eq!(CELL_ZERO.score(), 0);
assert_eq!(CELL_ZERO.dir(), Dir::None);
}
#[test]
fn cell_debug_shows_score_and_dir() {
let s = format!("{:?}", Cell::new(5, Dir::Left));
assert!(s.contains("Cell"));
assert!(s.contains("score"));
assert!(s.contains("Left"));
}
#[test]
fn matrix_zero_and_resize_grow() {
let mut m = SWMatrix::zero(2, 3);
assert_eq!(m.rows, 2);
assert_eq!(m.cols, 3);
assert!(m.data.len() >= 6);
// Growing increases the backing storage.
m.resize(4, 4);
assert_eq!(m.rows, 4);
assert_eq!(m.cols, 4);
assert!(m.data.len() >= 16);
// Shrinking keeps the (larger) allocation but updates dims.
m.resize(1, 1);
assert_eq!(m.rows, 1);
assert_eq!(m.cols, 1);
}
}

View file

@ -37,6 +37,20 @@ fn empty_choice_never_matches() {
assert!(score("", "a").is_none());
}
#[test]
fn pattern_longer_than_max_pat_len_is_rejected() {
// Patterns over MAX_PAT_LEN (32) chars exceed the stack-allocated banding
// arrays, so the matcher rejects them gracefully rather than panicking.
let pattern = "a".repeat(40);
let choice = "a".repeat(50);
assert!(score(&choice, &pattern).is_none());
assert!(matcher().fuzzy_indices(&choice, &pattern).is_none());
// Also via the non-ASCII (char-buffer) path.
let pattern_u = "é".repeat(40);
let choice_u = "é".repeat(50);
assert!(score(&choice_u, &pattern_u).is_none());
}
#[test]
fn exact_match_scores_positive() {
assert!(score("hello", "hello").unwrap() > 0);
@ -411,6 +425,37 @@ fn no_use_last_match_prefers_first_occurrence() {
assert_eq!(got, vec![0, 1, 2], "expected second 'man' (indices 0,1,2), got {got:?}");
}
#[test]
fn range_with_use_last_match_prefers_later_occurrence() {
// fuzzy_match_range with use_last_match takes the `>=` tie-break branch in
// range_dp, choosing the rightmost matching column.
let m = ArinaeMatcher {
use_last_match: true,
..Default::default()
};
let (_score, begin, end) = m.fuzzy_match_range("man/man1/sk.1", "man").expect("should match");
assert_eq!(begin, 4);
assert_eq!(end, 6);
}
#[test]
fn range_with_gaps_walks_traceback() {
// A scattered match forces gap moves during the range traceback.
let m = ArinaeMatcher::default();
let (_score, begin, end) = m.fuzzy_match_range("a_b_c_d_e", "abe").expect("should match");
// First matched char is 'a' at 0, last is 'e' at 8.
assert_eq!(begin, 0);
assert_eq!(end, 8);
}
#[test]
fn range_typo_dead_rows_rejects_long_mismatch() {
// Typo-tolerant range matching over a choice with no viable alignment
// exercises the dead-row early-out in range_dp.
let m = ArinaeMatcher::new(crate::CaseMatching::Smart, true, false);
assert!(m.fuzzy_match_range("xxxxxxxxxxxxxxxx", "qwerty").is_none());
}
#[test]
fn first_match_inside_brackets_is_highlighted() {
// Regression test for skim-rs/skim#1075. `[paste] some paste` queried with
@ -436,3 +481,48 @@ fn first_match_inside_brackets_is_highlighted() {
);
}
}
// ----- fuzzy_match_range edge cases -----
/// Empty pattern / choice short-circuit the range DP.
#[test]
fn range_empty_inputs() {
let m = matcher();
assert_eq!(m.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
assert_eq!(m.fuzzy_match_range("", "hello"), None);
}
/// `fuzzy_match_range` over non-ASCII text must agree with `fuzzy_indices`,
/// exercising the `char`-buffer (non-ASCII) branch of `run_range`.
#[test]
fn range_non_ascii_consistent_with_indices() {
let cases = [
("héllo wörld", "hw"),
("café taverne", "café"),
("naïve élégance", "néé"),
("日本語テキスト", "本テ"),
];
let matchers = [matcher(), matcher_typos()];
for m in &matchers {
for &(choice, pattern) in &cases {
let range = m.fuzzy_match_range(choice, pattern);
let full = m.fuzzy_indices(choice, pattern);
match (range, full) {
(None, None) => {}
(Some((rs, rb, re)), Some((fs, fidx))) => {
assert_eq!(rs, fs, "score mismatch for ({choice}, {pattern})");
assert_eq!(rb, fidx.first().copied().unwrap_or_default());
assert_eq!(re, fidx.last().copied().unwrap_or_default());
}
_ => panic!("range/indices disagreement for ({choice}, {pattern})"),
}
}
}
}
/// Non-ASCII no-match returns None through the `char`-buffer range branch.
#[test]
fn range_non_ascii_no_match() {
assert_eq!(matcher().fuzzy_match_range("café", "zzz"), None);
assert_eq!(matcher_typos().fuzzy_match_range("日本語", "xyz"), None);
}

View file

@ -467,6 +467,7 @@ fn print_dp(line: &str, pattern: &str, dp: &[Vec<Score>]) {
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::fuzzy_matcher::util::{assert_order, wrap_matches};
@ -519,4 +520,43 @@ mod tests {
// score(PRINT) > kMinScore
assert_order(&matcher, "Int", &["int", "INT", "PRINT"]);
}
#[test]
fn respect_case_is_sensitive() {
let matcher = ClangdMatcher::default().respect_case();
assert!(matcher.fuzzy_match("Foo", "Fo").is_some());
assert!(matcher.fuzzy_match("foo", "Fo").is_none());
}
#[test]
fn ignore_case_is_insensitive() {
let matcher = ClangdMatcher::default().ignore_case();
assert!(matcher.fuzzy_match("FOO", "foo").is_some());
assert!(matcher.fuzzy_match("foo", "FOO").is_some());
}
#[test]
fn smart_case_uppercase_pattern_is_sensitive() {
let matcher = ClangdMatcher::default().smart_case();
// Uppercase in the pattern makes matching case-sensitive.
assert!(matcher.fuzzy_match("Foo", "Fo").is_some());
assert!(matcher.fuzzy_match("foo", "Fo").is_none());
// All-lowercase pattern matches case-insensitively.
assert!(matcher.fuzzy_match("FOO", "fo").is_some());
}
#[test]
fn use_cache_toggle_is_chainable() {
// Enabling the cache explicitly still produces matches.
let matcher = ClangdMatcher::default().use_cache(true);
assert!(matcher.fuzzy_indices("foobar", "fb").is_some());
}
#[test]
fn fuzzy_match_range_spans_match() {
let matcher = ClangdMatcher::default().ignore_case();
let (_score, begin, end) = matcher.fuzzy_match_range("foobar", "fb").unwrap();
assert!(begin <= end);
assert!(matcher.fuzzy_match_range("foobar", "zzz").is_none());
}
}

View file

@ -88,3 +88,69 @@ impl FuzzyMatcher for FrizbeeMatcher {
.map(ScoreType::from)
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::fuzzy_matcher::FuzzyMatcher;
#[test]
fn matches_subsequence() {
let m = FrizbeeMatcher::default();
assert!(m.fuzzy_match("foobar", "foo").is_some());
assert!(m.fuzzy_indices("foobar", "foo").is_some());
}
#[test]
fn respect_case_variant() {
let m = FrizbeeMatcher::default().case(CaseMatching::Respect);
assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
}
#[test]
fn smart_case_variant() {
let m = FrizbeeMatcher::default().case(CaseMatching::Smart);
// Uppercase pattern triggers the case bonus branch.
assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
// Lowercase pattern -> no bonus.
assert!(m.fuzzy_indices("foobar", "foo").is_some());
}
#[test]
fn ignore_case_variant() {
let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
assert!(m.fuzzy_match("FOOBAR", "foo").is_some());
}
#[test]
fn max_typos_tolerates_mismatch() {
let m = FrizbeeMatcher::default().max_typos(Some(1));
assert!(m.fuzzy_match("foobar", "fxo").is_some());
}
#[test]
fn fuzzy_indices_ignore_case() {
// Ignore case → matching_case_bonus is 0 in fuzzy_indices.
let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
assert!(m.fuzzy_indices("FOOBAR", "foo").is_some());
}
#[test]
fn fuzzy_indices_no_match_returns_none() {
// A non-subsequence pattern exercises the None branch.
let m = FrizbeeMatcher::default();
assert!(m.fuzzy_indices("foobar", "zzz").is_none());
}
#[test]
fn fuzzy_match_respect_and_smart_case() {
// fuzzy_match (score-only) across the Respect and Smart case arms.
let respect = FrizbeeMatcher::default().case(CaseMatching::Respect);
assert!(respect.fuzzy_match("FooBar", "Foo").is_some());
let smart = FrizbeeMatcher::default().case(CaseMatching::Smart);
assert!(smart.fuzzy_match("FooBar", "Foo").is_some());
assert!(smart.fuzzy_match("foobar", "foo").is_some());
}
}

View file

@ -970,249 +970,5 @@ pub fn fuzzy_match(choice: &str, pattern: &str) -> Option<ScoreType> {
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::fuzzy_matcher::util::{assert_order, wrap_matches};
fn wrap_fuzzy_match(choice: &str, pattern: &str) -> Option<String> {
let (_score, indices) = fuzzy_indices(choice, pattern)?;
Some(wrap_matches(choice, &indices))
}
#[test]
fn test_no_match() {
assert_eq!(None, fuzzy_match("abc", "abx"));
assert_eq!(None, fuzzy_match("abc", "d"));
assert_eq!(None, fuzzy_match("", "a"));
}
#[test]
fn test_has_match() {
assert!(fuzzy_match("axbycz", "abc").is_some());
assert!(fuzzy_match("axbycz", "xyz").is_some());
assert!(fuzzy_match("abc", "abc").is_some());
}
#[test]
fn test_exact_match_is_max() {
let matcher = FzyMatcher::default().ignore_case();
let score = matcher.fuzzy_match("abc", "abc").unwrap();
assert!(score > 1_000_000);
}
#[test]
fn test_match_indices() {
assert_eq!("[a]x[b]y[c]z", &wrap_fuzzy_match("axbycz", "abc").unwrap());
assert_eq!("a[x]b[y]c[z]", &wrap_fuzzy_match("axbycz", "xyz").unwrap());
}
#[test]
fn test_consecutive_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let consecutive = matcher.fuzzy_match("foobar", "foo").unwrap();
let scattered = matcher.fuzzy_match("fxoxo", "foo").unwrap();
assert!(
consecutive > scattered,
"consecutive={consecutive} > scattered={scattered}"
);
}
#[test]
fn test_word_boundary_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let boundary = matcher.fuzzy_match("foo_bar_baz", "fbb").unwrap();
let inner = matcher.fuzzy_match("fooobarbaz", "fbb").unwrap();
assert!(boundary > inner, "boundary={boundary} > inner={inner}");
}
#[test]
fn test_path_separator_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let path = matcher.fuzzy_match("src/lib/foo.rs", "foo").unwrap();
let no_path = matcher.fuzzy_match("srcxlibxfoo.rs", "foo").unwrap();
assert!(path > no_path, "path={path} > no_path={no_path}");
}
#[test]
fn test_camel_case_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let camel = matcher.fuzzy_match("FooBarBaz", "fbb").unwrap();
let no_camel = matcher.fuzzy_match("foobarbaz", "fbb").unwrap();
assert!(camel > no_camel, "camel={camel} > no_camel={no_camel}");
}
#[test]
fn test_shorter_match_preferred() {
let matcher = FzyMatcher::default().ignore_case();
let short = matcher.fuzzy_match("ab", "ab").unwrap();
let long = matcher.fuzzy_match("axxxxxxb", "ab").unwrap();
assert!(short > long, "short={short} > long={long}");
}
#[test]
fn test_match_quality_ordering() {
let matcher = FzyMatcher::default();
assert_order(&matcher, "monad", &["monad", "Monad", "mONAD"]);
assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]);
assert_order(&matcher, "ma", &["map", "many", "maximum"]);
}
#[test]
fn test_unicode_match() {
let matcher = FzyMatcher::default().ignore_case();
let result = matcher.fuzzy_indices("Hello, 世界", "H世");
assert!(result.is_some());
let (_, indices) = result.unwrap();
assert_eq!(indices.as_slice(), &[0, 7]);
}
#[test]
fn test_smart_case() {
let matcher = FzyMatcher::default().smart_case();
assert!(matcher.fuzzy_match("FooBar", "foobar").is_some());
assert!(matcher.fuzzy_match("foobar", "FooBar").is_none());
assert!(matcher.fuzzy_match("FooBar", "FooBar").is_some());
}
#[test]
fn test_respect_case() {
let matcher = FzyMatcher::default().respect_case();
assert!(matcher.fuzzy_match("abc", "ABC").is_none());
assert!(matcher.fuzzy_match("ABC", "ABC").is_some());
}
#[test]
fn test_long_haystack() {
let matcher = FzyMatcher::default().ignore_case();
let long = "a".repeat(MATCH_MAX_LEN + 1);
assert_eq!(None, matcher.fuzzy_match(&long, "a"));
}
// -----------------------------------------------------------------------
// Typo-tolerant matching tests
// -----------------------------------------------------------------------
#[test]
fn test_typo_no_typos_behaves_like_default() {
let strict = FzyMatcher::default().ignore_case();
let typo0 = FzyMatcher::default().ignore_case().max_typos(Some(0));
assert!(strict.fuzzy_match("axbycz", "abc").is_some());
assert!(typo0.fuzzy_match("axbycz", "abc").is_some());
assert!(strict.fuzzy_match("abc", "abx").is_none());
assert!(typo0.fuzzy_match("abc", "abx").is_none());
}
#[test]
fn test_typo_substitution_single() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(matcher.fuzzy_match("abc", "abx").is_some(), "substitution: 'x' for 'c'");
}
#[test]
fn test_typo_substitution_returns_none_when_too_many_typos() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(
matcher.fuzzy_match("abc", "ayx").is_none(),
"2 typos needed but only 1 allowed"
);
let matcher2 = FzyMatcher::default().ignore_case().max_typos(Some(2));
assert!(matcher2.fuzzy_match("abc", "ayx").is_some(), "2 typos allowed");
}
#[test]
fn test_typo_needle_deletion() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(matcher.fuzzy_match("abd", "abcd").is_some(), "needle deletion of 'c'");
let strict = FzyMatcher::default().ignore_case();
assert!(strict.fuzzy_match("abd", "abcd").is_none());
}
#[test]
fn test_typo_exact_match_scores_higher_than_typo_match() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let exact = matcher.fuzzy_match("abc", "abc").unwrap();
let typo = matcher.fuzzy_match("axc", "abc").unwrap();
assert!(exact > typo, "exact ({exact}) > typo ({typo})");
}
#[test]
fn test_typo_subsequence_beats_typo() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let subseq = matcher.fuzzy_match("axbycz", "abc").unwrap();
let typo = matcher.fuzzy_match("abx", "abc").unwrap();
assert!(subseq > typo, "subsequence ({subseq}) > typo ({typo})");
}
#[test]
fn test_typo_indices_substitution() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let result = matcher.fuzzy_indices("abx", "abc");
assert!(result.is_some());
let (_, indices) = result.unwrap();
assert_eq!(indices.as_slice(), &[0, 1, 2]);
}
#[test]
fn test_typo_indices_needle_deletion() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let result = matcher.fuzzy_indices("abd", "abcd");
assert!(result.is_some());
let (_, indices) = result.unwrap();
// 'a'→0, 'b'→1, 'c' deleted (no index), 'd'→2
assert_eq!(indices.as_slice(), &[0, 1, 2]);
}
#[test]
fn test_typo_max_typos_none_is_zero_overhead() {
let default = FzyMatcher::default().ignore_case();
let explicit_none = FzyMatcher::default().ignore_case().max_typos(None);
let choices = ["foobar", "axbycz", "src/lib/foo.rs", "FooBarBaz"];
let pattern = "foo";
for choice in &choices {
assert_eq!(
default.fuzzy_match(choice, pattern),
explicit_none.fuzzy_match(choice, pattern),
"max_typos(None) should match default for '{choice}'"
);
}
}
#[test]
fn test_typo_realistic_filename() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let result = matcher.fuzzy_match("controller", "controllr");
assert!(
result.is_some(),
"should match 'controller' with needle 'controllr' (1 typo)"
);
}
#[test]
fn test_typo_two_typos() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(2));
assert!(matcher.fuzzy_match("abc", "xyz").is_none());
assert!(matcher.fuzzy_match("abc", "axz").is_some());
}
#[test]
fn test_typo_empty_pattern() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert_eq!(None, matcher.fuzzy_match("abc", ""));
}
#[test]
fn test_typo_pattern_longer_than_haystack() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(matcher.fuzzy_match("ab", "abc").is_some(), "delete 'c' from needle");
assert!(matcher.fuzzy_match("a", "abc").is_none());
let matcher2 = FzyMatcher::default().ignore_case().max_typos(Some(2));
assert!(matcher2.fuzzy_match("a", "abc").is_some());
}
}
#[path = "fzy_tests.rs"]
mod tests;

View file

@ -0,0 +1,339 @@
//! Unit tests for the fzy fuzzy matcher (see [`super`]).
use super::*;
use crate::fuzzy_matcher::util::{assert_order, wrap_matches};
fn wrap_fuzzy_match(choice: &str, pattern: &str) -> Option<String> {
let (_score, indices) = fuzzy_indices(choice, pattern)?;
Some(wrap_matches(choice, &indices))
}
#[test]
fn test_no_match() {
assert_eq!(None, fuzzy_match("abc", "abx"));
assert_eq!(None, fuzzy_match("abc", "d"));
assert_eq!(None, fuzzy_match("", "a"));
}
#[test]
fn test_has_match() {
assert!(fuzzy_match("axbycz", "abc").is_some());
assert!(fuzzy_match("axbycz", "xyz").is_some());
assert!(fuzzy_match("abc", "abc").is_some());
}
#[test]
fn test_exact_match_is_max() {
let matcher = FzyMatcher::default().ignore_case();
let score = matcher.fuzzy_match("abc", "abc").unwrap();
assert!(score > 1_000_000);
}
#[test]
fn test_match_indices() {
assert_eq!("[a]x[b]y[c]z", &wrap_fuzzy_match("axbycz", "abc").unwrap());
assert_eq!("a[x]b[y]c[z]", &wrap_fuzzy_match("axbycz", "xyz").unwrap());
}
#[test]
fn test_consecutive_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let consecutive = matcher.fuzzy_match("foobar", "foo").unwrap();
let scattered = matcher.fuzzy_match("fxoxo", "foo").unwrap();
assert!(
consecutive > scattered,
"consecutive={consecutive} > scattered={scattered}"
);
}
#[test]
fn test_word_boundary_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let boundary = matcher.fuzzy_match("foo_bar_baz", "fbb").unwrap();
let inner = matcher.fuzzy_match("fooobarbaz", "fbb").unwrap();
assert!(boundary > inner, "boundary={boundary} > inner={inner}");
}
#[test]
fn test_path_separator_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let path = matcher.fuzzy_match("src/lib/foo.rs", "foo").unwrap();
let no_path = matcher.fuzzy_match("srcxlibxfoo.rs", "foo").unwrap();
assert!(path > no_path, "path={path} > no_path={no_path}");
}
#[test]
fn test_camel_case_bonus() {
let matcher = FzyMatcher::default().ignore_case();
let camel = matcher.fuzzy_match("FooBarBaz", "fbb").unwrap();
let no_camel = matcher.fuzzy_match("foobarbaz", "fbb").unwrap();
assert!(camel > no_camel, "camel={camel} > no_camel={no_camel}");
}
#[test]
fn test_shorter_match_preferred() {
let matcher = FzyMatcher::default().ignore_case();
let short = matcher.fuzzy_match("ab", "ab").unwrap();
let long = matcher.fuzzy_match("axxxxxxb", "ab").unwrap();
assert!(short > long, "short={short} > long={long}");
}
#[test]
fn test_match_quality_ordering() {
let matcher = FzyMatcher::default();
assert_order(&matcher, "monad", &["monad", "Monad", "mONAD"]);
assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]);
assert_order(&matcher, "ma", &["map", "many", "maximum"]);
}
#[test]
fn test_unicode_match() {
let matcher = FzyMatcher::default().ignore_case();
let result = matcher.fuzzy_indices("Hello, 世界", "H世");
assert!(result.is_some());
let (_, indices) = result.unwrap();
assert_eq!(indices.as_slice(), &[0, 7]);
}
#[test]
fn test_smart_case() {
let matcher = FzyMatcher::default().smart_case();
assert!(matcher.fuzzy_match("FooBar", "foobar").is_some());
assert!(matcher.fuzzy_match("foobar", "FooBar").is_none());
assert!(matcher.fuzzy_match("FooBar", "FooBar").is_some());
}
#[test]
fn test_respect_case() {
let matcher = FzyMatcher::default().respect_case();
assert!(matcher.fuzzy_match("abc", "ABC").is_none());
assert!(matcher.fuzzy_match("ABC", "ABC").is_some());
}
#[test]
fn test_long_haystack() {
let matcher = FzyMatcher::default().ignore_case();
let long = "a".repeat(MATCH_MAX_LEN + 1);
assert_eq!(None, matcher.fuzzy_match(&long, "a"));
}
// -----------------------------------------------------------------------
// Typo-tolerant matching tests
// -----------------------------------------------------------------------
#[test]
fn test_typo_no_typos_behaves_like_default() {
let strict = FzyMatcher::default().ignore_case();
let typo0 = FzyMatcher::default().ignore_case().max_typos(Some(0));
assert!(strict.fuzzy_match("axbycz", "abc").is_some());
assert!(typo0.fuzzy_match("axbycz", "abc").is_some());
assert!(strict.fuzzy_match("abc", "abx").is_none());
assert!(typo0.fuzzy_match("abc", "abx").is_none());
}
#[test]
fn test_typo_substitution_single() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(matcher.fuzzy_match("abc", "abx").is_some(), "substitution: 'x' for 'c'");
}
#[test]
fn test_typo_substitution_returns_none_when_too_many_typos() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(
matcher.fuzzy_match("abc", "ayx").is_none(),
"2 typos needed but only 1 allowed"
);
let matcher2 = FzyMatcher::default().ignore_case().max_typos(Some(2));
assert!(matcher2.fuzzy_match("abc", "ayx").is_some(), "2 typos allowed");
}
#[test]
fn test_typo_needle_deletion() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(matcher.fuzzy_match("abd", "abcd").is_some(), "needle deletion of 'c'");
let strict = FzyMatcher::default().ignore_case();
assert!(strict.fuzzy_match("abd", "abcd").is_none());
}
#[test]
fn test_typo_exact_match_scores_higher_than_typo_match() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let exact = matcher.fuzzy_match("abc", "abc").unwrap();
let typo = matcher.fuzzy_match("axc", "abc").unwrap();
assert!(exact > typo, "exact ({exact}) > typo ({typo})");
}
#[test]
fn test_typo_subsequence_beats_typo() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let subseq = matcher.fuzzy_match("axbycz", "abc").unwrap();
let typo = matcher.fuzzy_match("abx", "abc").unwrap();
assert!(subseq > typo, "subsequence ({subseq}) > typo ({typo})");
}
#[test]
fn test_typo_indices_substitution() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let result = matcher.fuzzy_indices("abx", "abc");
assert!(result.is_some());
let (_, indices) = result.unwrap();
assert_eq!(indices.as_slice(), &[0, 1, 2]);
}
#[test]
fn test_typo_indices_needle_deletion() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let result = matcher.fuzzy_indices("abd", "abcd");
assert!(result.is_some());
let (_, indices) = result.unwrap();
// 'a'→0, 'b'→1, 'c' deleted (no index), 'd'→2
assert_eq!(indices.as_slice(), &[0, 1, 2]);
}
#[test]
fn test_typo_max_typos_none_is_zero_overhead() {
let default = FzyMatcher::default().ignore_case();
let explicit_none = FzyMatcher::default().ignore_case().max_typos(None);
let choices = ["foobar", "axbycz", "src/lib/foo.rs", "FooBarBaz"];
let pattern = "foo";
for choice in &choices {
assert_eq!(
default.fuzzy_match(choice, pattern),
explicit_none.fuzzy_match(choice, pattern),
"max_typos(None) should match default for '{choice}'"
);
}
}
#[test]
fn test_typo_realistic_filename() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
let result = matcher.fuzzy_match("controller", "controllr");
assert!(
result.is_some(),
"should match 'controller' with needle 'controllr' (1 typo)"
);
}
#[test]
fn test_typo_two_typos() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(2));
assert!(matcher.fuzzy_match("abc", "xyz").is_none());
assert!(matcher.fuzzy_match("abc", "axz").is_some());
}
#[test]
fn test_typo_empty_pattern() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert_eq!(None, matcher.fuzzy_match("abc", ""));
}
#[test]
fn test_typo_pattern_longer_than_haystack() {
let matcher = FzyMatcher::default().ignore_case().max_typos(Some(1));
assert!(matcher.fuzzy_match("ab", "abc").is_some(), "delete 'c' from needle");
assert!(matcher.fuzzy_match("a", "abc").is_none());
let matcher2 = FzyMatcher::default().ignore_case().max_typos(Some(2));
assert!(matcher2.fuzzy_match("a", "abc").is_some());
}
#[test]
fn test_uppercase_after_separator_bonuses() {
// An uppercase pattern char following a separator earns that separator's
// bonus (slash / dash / dot), exercising the char_class==2 bonus arms.
let m = FzyMatcher::default().respect_case();
assert!(m.fuzzy_match("foo/Bar", "B").is_some());
assert!(m.fuzzy_match("foo-Bar", "B").is_some());
assert!(m.fuzzy_match("foo.Bar", "B").is_some());
// camelCase: uppercase preceded by a lowercase letter.
assert!(m.fuzzy_match("fooBar", "B").is_some());
// Uppercase preceded by another uppercase earns no bonus (the `_ => 0` arm).
assert!(m.fuzzy_match("ABc", "B").is_some());
}
#[test]
fn test_use_cache_setter_is_chainable() {
// The use_cache builder setter returns a working matcher (cache enabled).
let m = FzyMatcher::default().ignore_case().use_cache(true);
assert!(m.fuzzy_match("foobar", "fb").is_some());
}
#[test]
fn test_exact_length_match_returns_all_indices() {
// When pattern and choice are the same length and fully match, fzy_score
// short-circuits to SCORE_MAX and returns every index.
let m = FzyMatcher::default().ignore_case();
let (_score, indices) = m.fuzzy_indices("abc", "abc").unwrap();
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn test_case_sensitive_subsequence_scoring() {
// respect_case with n < m forces fzy_score's `is_match` to use the
// case-sensitive comparison branch (needle[i] == haystack[j]) on a real,
// non-degenerate match (cheap_matches passes, then the DP runs).
let m = FzyMatcher::default().respect_case();
// "abc" is a strict subsequence of "aXbYc" with matching case.
let (score, indices) = m.fuzzy_indices("aXbYc", "abc").unwrap();
assert_eq!(indices, vec![0, 2, 4]);
assert!(score > 0);
// A case mismatch in the middle must fail under respect_case.
assert!(m.fuzzy_match("aXBYc", "abc").is_none());
// Score-only path (fuzzy_match) over the same case-sensitive subsequence.
assert!(m.fuzzy_match("aXbYc", "abc").is_some());
}
#[test]
fn test_dp_cell_match_at_first_haystack_char_for_later_needle() {
// Exercises the `i > 0 && j == 0` matched-cell edge in fzy_score: the
// second needle char ('b') equals the first haystack char ('b'), which the
// DP evaluates even though it cannot be part of an in-order match.
let m = FzyMatcher::default().ignore_case();
// "ab" is a subsequence of "bab" (a@1, b@2); the DP still visits (i=1,j=0).
let (_score, indices) = m.fuzzy_indices("bab", "ab").unwrap();
assert_eq!(indices, vec![1, 2]);
}
#[test]
fn test_case_sensitive_typo_substitution() {
// respect_case + typos: the substitution path must use the case-sensitive
// comparison branch in both the rolling (fuzzy_match) and full
// (fuzzy_indices) typo DP routines.
let m = FzyMatcher::default().respect_case().max_typos(Some(1));
// 'X' substitutes for 'c' (one typo), all other chars match case exactly.
assert!(m.fuzzy_match("abXd", "abcd").is_some());
let (_score, indices) = m.fuzzy_indices("abXd", "abcd").unwrap();
assert_eq!(indices.len(), 4);
// A case-only difference still costs a typo under respect_case.
let strict = FzyMatcher::default().respect_case();
assert!(strict.fuzzy_match("abCd", "abcd").is_none());
assert!(m.fuzzy_match("abCd", "abcd").is_some());
}
#[test]
fn test_typo_indices_zero_allowed_falls_back_to_none() {
// fuzzy_indices with max_typos(Some(0)): when the cheap subsequence check
// fails, the `max_t == 0` guard returns None without entering the DP.
let m = FzyMatcher::default().ignore_case().max_typos(Some(0));
assert!(m.fuzzy_indices("abc", "abx").is_none());
// And a clean subsequence still matches through the fast path.
assert!(m.fuzzy_indices("axbxc", "abc").is_some());
}
#[test]
fn test_typo_indices_pattern_too_long_for_haystack() {
// fuzzy_indices typo slow-path length guard: n > m + max_t returns None.
let m = FzyMatcher::default().ignore_case().max_typos(Some(1));
// pattern len 4, haystack len 2, 1 typo allowed -> 4 > 2 + 1.
assert!(m.fuzzy_indices("ab", "abcd").is_none());
// One needle deletion is enough when the gap is exactly max_t.
assert!(m.fuzzy_indices("abc", "abcd").is_some());
}

View file

@ -46,3 +46,43 @@ pub trait FuzzyMatcher: Send + Sync {
})
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
/// A matcher that only implements `fuzzy_indices`, so it exercises the
/// default `fuzzy_match` / `fuzzy_match_range` implementations.
struct StubMatcher;
impl FuzzyMatcher for StubMatcher {
fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)> {
if pattern.is_empty() {
return Some((0, vec![]));
}
// Match only when the pattern is a prefix of the choice.
choice
.starts_with(pattern)
.then(|| (10, (0..pattern.chars().count()).collect()))
}
}
#[test]
fn default_fuzzy_match_uses_indices_score() {
assert_eq!(StubMatcher.fuzzy_match("hello", "he"), Some(10));
assert_eq!(StubMatcher.fuzzy_match("hello", "xy"), None);
}
#[test]
fn default_fuzzy_match_range_spans_first_to_last() {
assert_eq!(StubMatcher.fuzzy_match_range("hello", "hel"), Some((10, 0, 2)));
assert_eq!(StubMatcher.fuzzy_match_range("hello", "zz"), None);
}
#[test]
fn default_fuzzy_match_range_empty_indices_default_to_zero() {
// 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)));
}
}

View file

@ -814,176 +814,5 @@ impl FuzzyMatcher for SkimMatcherV2 {
}
#[cfg(test)]
mod tests {
use crate::fuzzy_matcher::util::{assert_order, wrap_matches};
use super::*;
fn wrap_fuzzy_match(matcher: &dyn FuzzyMatcher, line: &str, pattern: &str) -> Option<String> {
let (score, indices) = matcher.fuzzy_indices(line, pattern)?;
println!("score: {score:?}, indices: {indices:?}");
Some(wrap_matches(line, &indices))
}
#[test]
fn test_match_or_not() {
let matcher = SkimMatcherV2::default();
assert_eq!(Some(0), matcher.fuzzy_match("", ""));
assert_eq!(Some(0), matcher.fuzzy_match("abcdefaghi", ""));
assert_eq!(None, matcher.fuzzy_match("", "a"));
assert_eq!(None, matcher.fuzzy_match("abcdefaghi", ""));
assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
assert_eq!("[a]x[b]y[c]z", &wrap_fuzzy_match(&matcher, "axbycz", "abc").unwrap());
assert_eq!("a[x]b[y]c[z]", &wrap_fuzzy_match(&matcher, "axbycz", "xyz").unwrap());
assert_eq!(
"[H]ello, [世]界",
&wrap_fuzzy_match(&matcher, "Hello, 世界", "H世").unwrap()
);
}
#[test]
fn test_match_quality() {
let matcher = SkimMatcherV2::default().ignore_case();
// initials
assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]);
assert_order(&matcher, "CC", &["CamelCase", "camelCase", "camelcase"]);
assert_order(&matcher, "cC", &["camelCase", "CamelCase", "camelcase"]);
assert_order(
&matcher,
"cc",
&["camel case", "camelCase", "CamelCase", "camelcase", "camel ace"],
);
assert_order(
&matcher,
"Da.Te",
&["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.text"],
);
// prefix
assert_order(&matcher, "is", &["isIEEE", "inSuf"]);
// shorter
assert_order(&matcher, "ma", &["map", "many", "maximum"]);
assert_order(&matcher, "print", &["printf", "sprintf"]);
// score(PRINT) = kMinScore
assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]);
// score(PRINT) > kMinScore
assert_order(&matcher, "Int", &["int", "INT", "PRINT"]);
}
fn simple_match(
matcher: &SkimMatcherV2,
choice: &str,
pattern: &str,
case_sensitive: bool,
with_pos: bool,
) -> Option<(ScoreType, Vec<IndexType>)> {
let choice: Vec<char> = choice.chars().collect();
let pattern: Vec<char> = pattern.chars().collect();
let first_match_indices = cheap_matches(&choice, &pattern, case_sensitive)?;
matcher.simple_match(&choice, &pattern, &first_match_indices, case_sensitive, with_pos)
}
#[test]
fn test_match_or_not_simple() {
let matcher = SkimMatcherV2::default();
assert_eq!(
simple_match(&matcher, "axbycz", "xyz", false, true).unwrap().1,
vec![1, 3, 5]
);
assert_eq!(simple_match(&matcher, "", "", false, false), Some((0, vec![])));
assert_eq!(
simple_match(&matcher, "abcdefaghi", "", false, false),
Some((0, vec![]))
);
assert_eq!(simple_match(&matcher, "", "a", false, false), None);
assert_eq!(simple_match(&matcher, "abcdefaghi", "", false, false), None);
assert_eq!(simple_match(&matcher, "abc", "abx", false, false), None);
assert_eq!(
simple_match(&matcher, "axbycz", "abc", false, true).unwrap().1,
vec![0, 2, 4]
);
assert_eq!(
simple_match(&matcher, "axbycz", "xyz", false, true).unwrap().1,
vec![1, 3, 5]
);
assert_eq!(
simple_match(&matcher, "Hello, 世界", "H世", false, true).unwrap().1,
vec![0, 7]
);
}
#[test]
fn test_match_or_not_v2() {
let matcher = SkimMatcherV2::default().debug(true);
assert_eq!(matcher.fuzzy_match("", ""), Some(0));
assert_eq!(matcher.fuzzy_match("abcdefaghi", ""), Some(0));
assert_eq!(matcher.fuzzy_match("", "a"), None);
assert_eq!(matcher.fuzzy_match("abcdefaghi", ""), None);
assert_eq!(matcher.fuzzy_match("abc", "abx"), None);
assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
assert_eq!(&wrap_fuzzy_match(&matcher, "axbycz", "abc").unwrap(), "[a]x[b]y[c]z");
assert_eq!(&wrap_fuzzy_match(&matcher, "axbycz", "xyz").unwrap(), "a[x]b[y]c[z]");
assert_eq!(
&wrap_fuzzy_match(&matcher, "Hello, 世界", "H世").unwrap(),
"[H]ello, [世]界"
);
}
#[test]
fn test_case_option_v2() {
let matcher = SkimMatcherV2::default().ignore_case();
assert!(matcher.fuzzy_match("aBc", "abc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBC").is_some());
let matcher = SkimMatcherV2::default().respect_case();
assert!(matcher.fuzzy_match("aBc", "abc").is_none());
assert!(matcher.fuzzy_match("aBc", "aBc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBC").is_none());
let matcher = SkimMatcherV2::default().smart_case();
assert!(matcher.fuzzy_match("aBc", "abc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBC").is_none());
}
#[test]
fn test_matcher_quality_v2() {
let matcher = SkimMatcherV2::default();
assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]);
assert_order(
&matcher,
"cc",
&["camel case", "camelCase", "CamelCase", "camelcase", "camel ace"],
);
assert_order(
&matcher,
"Da.Te",
&["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.Text"],
);
assert_order(&matcher, "is", &["isIEEE", "inSuf"]);
assert_order(&matcher, "ma", &["map", "many", "maximum"]);
assert_order(&matcher, "print", &["printf", "sprintf"]);
assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]);
assert_order(&matcher, "int", &["int", "INT", "PRINT"]);
}
#[test]
fn test_reuse_should_not_affect_indices() {
let matcher = SkimMatcherV2::default();
let pattern = "139";
for num in 0..10000 {
let choice = num.to_string();
if let Some((_score, indices)) = matcher.fuzzy_indices(&choice, pattern) {
assert_eq!(indices.len(), 3);
}
}
}
}
#[path = "skim_tests.rs"]
mod tests;

View file

@ -0,0 +1,204 @@
use crate::fuzzy_matcher::util::{assert_order, wrap_matches};
use super::*;
fn wrap_fuzzy_match(matcher: &dyn FuzzyMatcher, line: &str, pattern: &str) -> Option<String> {
let (score, indices) = matcher.fuzzy_indices(line, pattern)?;
println!("score: {score:?}, indices: {indices:?}");
Some(wrap_matches(line, &indices))
}
#[test]
fn test_match_or_not() {
let matcher = SkimMatcherV2::default();
assert_eq!(Some(0), matcher.fuzzy_match("", ""));
assert_eq!(Some(0), matcher.fuzzy_match("abcdefaghi", ""));
assert_eq!(None, matcher.fuzzy_match("", "a"));
assert_eq!(None, matcher.fuzzy_match("abcdefaghi", ""));
assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
assert_eq!("[a]x[b]y[c]z", &wrap_fuzzy_match(&matcher, "axbycz", "abc").unwrap());
assert_eq!("a[x]b[y]c[z]", &wrap_fuzzy_match(&matcher, "axbycz", "xyz").unwrap());
assert_eq!(
"[H]ello, [世]界",
&wrap_fuzzy_match(&matcher, "Hello, 世界", "H世").unwrap()
);
}
#[test]
fn test_match_quality() {
let matcher = SkimMatcherV2::default().ignore_case();
// initials
assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]);
assert_order(&matcher, "CC", &["CamelCase", "camelCase", "camelcase"]);
assert_order(&matcher, "cC", &["camelCase", "CamelCase", "camelcase"]);
assert_order(
&matcher,
"cc",
&["camel case", "camelCase", "CamelCase", "camelcase", "camel ace"],
);
assert_order(
&matcher,
"Da.Te",
&["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.text"],
);
// prefix
assert_order(&matcher, "is", &["isIEEE", "inSuf"]);
// shorter
assert_order(&matcher, "ma", &["map", "many", "maximum"]);
assert_order(&matcher, "print", &["printf", "sprintf"]);
// score(PRINT) = kMinScore
assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]);
// score(PRINT) > kMinScore
assert_order(&matcher, "Int", &["int", "INT", "PRINT"]);
}
fn simple_match(
matcher: &SkimMatcherV2,
choice: &str,
pattern: &str,
case_sensitive: bool,
with_pos: bool,
) -> Option<(ScoreType, Vec<IndexType>)> {
let choice: Vec<char> = choice.chars().collect();
let pattern: Vec<char> = pattern.chars().collect();
let first_match_indices = cheap_matches(&choice, &pattern, case_sensitive)?;
matcher.simple_match(&choice, &pattern, &first_match_indices, case_sensitive, with_pos)
}
#[test]
fn test_match_or_not_simple() {
let matcher = SkimMatcherV2::default();
assert_eq!(
simple_match(&matcher, "axbycz", "xyz", false, true).unwrap().1,
vec![1, 3, 5]
);
assert_eq!(simple_match(&matcher, "", "", false, false), Some((0, vec![])));
assert_eq!(
simple_match(&matcher, "abcdefaghi", "", false, false),
Some((0, vec![]))
);
assert_eq!(simple_match(&matcher, "", "a", false, false), None);
assert_eq!(simple_match(&matcher, "abcdefaghi", "", false, false), None);
assert_eq!(simple_match(&matcher, "abc", "abx", false, false), None);
assert_eq!(
simple_match(&matcher, "axbycz", "abc", false, true).unwrap().1,
vec![0, 2, 4]
);
assert_eq!(
simple_match(&matcher, "axbycz", "xyz", false, true).unwrap().1,
vec![1, 3, 5]
);
assert_eq!(
simple_match(&matcher, "Hello, 世界", "H世", false, true).unwrap().1,
vec![0, 7]
);
}
#[test]
fn test_match_or_not_v2() {
let matcher = SkimMatcherV2::default().debug(true);
assert_eq!(matcher.fuzzy_match("", ""), Some(0));
assert_eq!(matcher.fuzzy_match("abcdefaghi", ""), Some(0));
assert_eq!(matcher.fuzzy_match("", "a"), None);
assert_eq!(matcher.fuzzy_match("abcdefaghi", ""), None);
assert_eq!(matcher.fuzzy_match("abc", "abx"), None);
assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
assert_eq!(&wrap_fuzzy_match(&matcher, "axbycz", "abc").unwrap(), "[a]x[b]y[c]z");
assert_eq!(&wrap_fuzzy_match(&matcher, "axbycz", "xyz").unwrap(), "a[x]b[y]c[z]");
assert_eq!(
&wrap_fuzzy_match(&matcher, "Hello, 世界", "H世").unwrap(),
"[H]ello, [世]界"
);
}
#[test]
fn test_case_option_v2() {
let matcher = SkimMatcherV2::default().ignore_case();
assert!(matcher.fuzzy_match("aBc", "abc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBC").is_some());
let matcher = SkimMatcherV2::default().respect_case();
assert!(matcher.fuzzy_match("aBc", "abc").is_none());
assert!(matcher.fuzzy_match("aBc", "aBc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBC").is_none());
let matcher = SkimMatcherV2::default().smart_case();
assert!(matcher.fuzzy_match("aBc", "abc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBc").is_some());
assert!(matcher.fuzzy_match("aBc", "aBC").is_none());
}
#[test]
fn test_matcher_quality_v2() {
let matcher = SkimMatcherV2::default();
assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]);
assert_order(
&matcher,
"cc",
&["camel case", "camelCase", "CamelCase", "camelcase", "camel ace"],
);
assert_order(
&matcher,
"Da.Te",
&["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.Text"],
);
assert_order(&matcher, "is", &["isIEEE", "inSuf"]);
assert_order(&matcher, "ma", &["map", "many", "maximum"]);
assert_order(&matcher, "print", &["printf", "sprintf"]);
assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]);
assert_order(&matcher, "int", &["int", "INT", "PRINT"]);
}
#[test]
fn test_reuse_should_not_affect_indices() {
let matcher = SkimMatcherV2::default();
let pattern = "139";
for num in 0..10000 {
let choice = num.to_string();
if let Some((_score, indices)) = matcher.fuzzy_indices(&choice, pattern) {
assert_eq!(indices.len(), 3);
}
}
}
#[test]
fn builder_setters_are_chainable() {
// score_config and use_cache builder setters return a working matcher.
let matcher = SkimMatcherV2::default()
.score_config(SkimScoreConfig::default())
.use_cache(true)
.debug(false);
assert!(matcher.fuzzy_match("foobar", "fb").is_some());
}
#[test]
fn element_limit_falls_back_to_simple_match() {
// A tiny element limit forces the simple_match path instead of the full DP.
let matcher = SkimMatcherV2::default().ignore_case().element_limit(1);
// Single-character pattern hits the dedicated one-char branch.
let (_score, indices) = matcher.fuzzy_indices("hello", "l").unwrap();
assert_eq!(indices.len(), 1);
// Multi-character pattern walks the reverse fill loop.
let (_score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
assert_eq!(indices.len(), 3);
// simple_match still rejects non-subsequences.
assert!(matcher.fuzzy_match("abc", "xyz").is_none());
}
#[test]
fn simple_match_empty_pattern_scores_zero() {
let matcher = SkimMatcherV2::default().element_limit(1);
assert_eq!(matcher.fuzzy_match("hello", ""), Some(0));
}

View file

@ -140,3 +140,61 @@ pub fn wrap_matches(line: &str, indices: &[IndexType]) -> String {
ret
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn char_equal_case_sensitive() {
assert!(char_equal('a', 'a', true));
assert!(!char_equal('a', 'A', true));
}
#[test]
fn char_equal_case_insensitive() {
assert!(char_equal('a', 'A', false));
assert!(!char_equal('a', 'b', false));
}
#[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));
}
#[test]
fn cheap_matches_subsequence() {
let choice: Vec<char> = "hello".chars().collect();
let pattern: Vec<char> = "hlo".chars().collect();
assert_eq!(cheap_matches(&choice, &pattern, true), Some(vec![0, 2, 4]));
}
#[test]
fn cheap_matches_no_match() {
let choice: Vec<char> = "hello".chars().collect();
let pattern: Vec<char> = "xyz".chars().collect();
assert_eq!(cheap_matches(&choice, &pattern, true), None);
}
#[test]
fn char_type_and_role() {
assert_eq!(char_type_of('a'), CharType::Lower);
assert_eq!(char_type_of('A'), CharType::Upper);
assert_eq!(char_type_of('1'), CharType::Number);
assert_eq!(char_type_of('-'), CharType::NonWord);
assert_eq!(char_role('o', 'B'), CharRole::Head);
assert_eq!(char_role('-', 'f'), CharRole::Head);
assert_eq!(char_role('F', 'o'), CharRole::Tail);
assert_eq!(char_role('H', 'T'), CharRole::Tail);
}
#[test]
fn wrap_matches_brackets_indices() {
assert_eq!(wrap_matches("hello", &[0, 4]), "[h]ell[o]");
assert_eq!(wrap_matches("hi", &[]), "hi");
}
}

View file

@ -552,394 +552,5 @@ fn escape_ansi(raw: &str) -> String {
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_strip_ansi() {
// Test basic ANSI color codes
// "\x1b[31mred\x1b[0m" has chars at positions: 0=ESC, 1=[, 2=3, 3=1, 4=m, 5=r, 6=e, 7=d, 8=ESC, 9=[, 10=0, 11=m
let (text, mapping) = strip_ansi("\x1b[31mred\x1b[0m");
assert_eq!(text, "red");
assert_eq!(mapping, vec![(5, 5), (6, 6), (7, 7)]);
let (text, mapping) = strip_ansi("\x1b[01;32mgreen\x1b[0m");
assert_eq!(text, "green");
assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10), (11, 11), (12, 12)]);
let (text, mapping) = strip_ansi("\x1b[01;34mblue\x1b[0m");
assert_eq!(text, "blue");
assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10), (11, 11)]);
// Test text without ANSI codes
let (text, mapping) = strip_ansi("plain text");
assert_eq!(text, "plain text");
assert_eq!(
mapping,
vec![
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9)
]
);
// Test multiple ANSI sequences
let (text, mapping) = strip_ansi("\x1b[31mred\x1b[0m and \x1b[32mgreen\x1b[0m");
assert_eq!(text, "red and green");
assert_eq!(
mapping,
vec![
(5, 5),
(6, 6),
(7, 7),
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16),
(22, 22),
(23, 23),
(24, 24),
(25, 25),
(26, 26)
]
);
// Test ANSI codes in the middle of text
let (text, mapping) = strip_ansi("be\x1b[01;34mf\x1b[0more");
assert_eq!(text, "before");
assert_eq!(mapping, vec![(0, 0), (1, 1), (10, 10), (15, 15), (16, 16), (17, 17)]);
// Test real ls --color output
let (text, mapping) = strip_ansi("\x1b[01;32mbench.sh\x1b[0m");
assert_eq!(text, "bench.sh");
assert_eq!(
mapping,
vec![
(8, 8),
(9, 9),
(10, 10),
(11, 11),
(12, 12),
(13, 13),
(14, 14),
(15, 15)
]
);
let (text, mapping) = strip_ansi("\x1b[01;34mbin\x1b[0m");
assert_eq!(text, "bin");
assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10)]);
// Test with multi-byte UTF-8 characters to verify byte vs char position difference
// "😀" is 4 bytes but 1 char - when followed by ANSI codes, byte and char positions diverge
let (text, mapping) = strip_ansi("😀\x1b[32mtext\x1b[0m");
assert_eq!(text, "😀text");
// Original: "😀\x1b[32mtext\x1b[0m"
// byte positions: 😀=0-3, \x1b=4, [=5, 3=6, 2=7, m=8, t=9, e=10, x=11, t=12, \x1b=13, [=14, 0=15, m=16
// char positions: 😀=0, \x1b=1, [=2, 3=3, 2=4, m=5, t=6, e=7, x=8, t=9, \x1b=10, [=11, 0=12, m=13
// After stripping: "😀text"
// stripped[0]='😀' -> (byte=0, char=0)
// stripped[1]='t' -> (byte=9, char=6) <- Here byte and char positions differ!
assert_eq!(mapping, vec![(0, 0), (9, 6), (10, 7), (11, 8), (12, 9)]);
}
#[test]
fn test_ansi_matching_and_display() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
// Create an item with ANSI codes
let input = "\x1b[32mgreen\x1b[0m text";
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new(
input,
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// text() should return stripped text for matching
assert_eq!(item.text(), "green text");
// Verify we have ANSI info
assert!(item.ansi_info().is_some());
// Create a match context as if we matched "text" (positions 6-10 in stripped string)
let context = DisplayContext {
score: 100,
matches: Matches::CharRange(6, 10),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().fg(Color::Yellow),
};
// display() should map the match positions back to the original ANSI text
let line = item.display(context);
// The line should have the original ANSI codes intact
// We can't easily verify the exact ANSI codes in the output, but we can check
// that it's not empty and has multiple spans (original text + highlighted match)
assert!(!line.spans.is_empty());
}
#[test]
fn test_ansi_char_indices_mapping() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
// Create an item with ANSI codes: "😀\x1b[32mtext\x1b[0m"
let input = "😀\x1b[32mtext\x1b[0m";
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new(
input,
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// text() should return "😀text"
assert_eq!(item.text(), "😀text");
// Match indices 1,2 in stripped text (the 't' and 'e')
let context = DisplayContext {
score: 100,
matches: Matches::CharIndices(vec![1, 2]),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().fg(Color::Yellow),
};
// display() should map these to positions 6,7 in original text
let line = item.display(context);
assert!(!line.spans.is_empty());
}
#[test]
fn test_text_returns_stripped() {
use crate::SkimItem;
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Test with ANSI enabled
let item_ansi = DefaultSkimItem::new(
"\x1b[31mred\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
assert_eq!(
item_ansi.text(),
"red",
"text() should return stripped text when ANSI is enabled"
);
// Test with ANSI disabled
let item_no_ansi = DefaultSkimItem::new(
"\x1b[31mred\x1b[0m",
false, // ansi_enabled
&[],
&[],
&delimiter,
);
assert_eq!(
item_no_ansi.text(),
"?[31mred?[0m",
"text() should return text with ? when ANSI is disabled"
);
}
#[test]
fn test_highlighting_applied() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// Create display context with yellow background highlight for character 0 (the 'g')
let context = DisplayContext {
score: 100,
matches: Matches::CharIndices(vec![0]),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
// The line should have spans with highlighting
// At least one span should have the yellow background
let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow));
assert!(has_highlight, "Highlighted character should have yellow background");
// The green foreground from ANSI should be preserved in at least one span
let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green));
assert!(has_green_fg, "ANSI green foreground should be preserved");
}
#[test]
fn test_char_range_highlighting() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// Create display context with yellow background highlight for characters 1-3 ('re')
let context = DisplayContext {
score: 100,
matches: Matches::CharRange(1, 3),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
// Should have multiple spans: before, highlighted, after
assert!(line.spans.len() >= 2, "Should have multiple spans for highlighting");
// At least one span should have the yellow background (the highlighted portion)
let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow));
assert!(has_highlight, "Highlighted characters should have yellow background");
// The green foreground from ANSI should be preserved
let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green));
assert!(has_green_fg, "ANSI green foreground should be preserved");
}
#[test]
fn test_byte_range_highlighting() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// Create display context with yellow background highlight for bytes 1-3 ('re' in stripped text)
let context = DisplayContext {
score: 100,
matches: Matches::ByteRange(1, 3),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
// Should have multiple spans for highlighting
assert!(!line.spans.is_empty(), "Should have spans");
// At least one span should have the yellow background (the highlighted portion)
let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow));
assert!(has_highlight, "Highlighted bytes should have yellow background");
// The green foreground from ANSI should be preserved
let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green));
assert!(has_green_fg, "ANSI green foreground should be preserved");
}
#[test]
fn test_matching_with_ansi_basic() {
use crate::SkimItem;
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen_text\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen_text\x1b[0m",
true, // ansi_enabled
&[],
&[], // no matching fields restriction
&delimiter,
);
// text() should return stripped text "green_text"
assert_eq!(item.text(), "green_text");
// With no matching_fields, get_matching_ranges should return None (match whole text)
assert!(item.get_matching_ranges().is_none());
// Verify the stripped_text and ansi_info are populated correctly
assert!(item.stripped_text().is_some());
assert!(item.ansi_info().is_some());
assert_eq!(item.stripped_text().unwrap(), "green_text");
}
#[test]
fn test_null_delimiter_with_matching_fields() {
use crate::SkimItem;
use crate::field::FieldRange;
use regex::Regex;
// Test with null byte delimiter and matching_fields
let delimiter = Regex::new("\x00").unwrap();
let text = "a\x00b\x00c";
// Create item with matching field 2
let item = DefaultSkimItem::new(
text,
false, // no ansi
&[], // no transform fields
&[FieldRange::Single(2)], // match field 2
&delimiter,
);
// text() should return text with null bytes stripped for display
assert_eq!(item.text(), "abc");
// get_matching_ranges should return the range for field 2 in the stripped text
let ranges = item.get_matching_ranges().expect("Should have matching ranges");
assert_eq!(ranges.len(), 1, "Should have one matching range");
// Field 2 is "b" which is at position 1 in the stripped text "abc"
assert_eq!(ranges[0], (1, 2), "Field 2 should be at position 1-2 in stripped text");
// Verify the substring matches what we expect
let stripped_text = item.text();
let field_text = &stripped_text[ranges[0].0..ranges[0].1];
assert_eq!(field_text, "b", "Field text should be 'b'");
}
}
#[path = "item_tests.rs"]
mod test;

View file

@ -520,3 +520,7 @@ fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<
Ok((command.spawn().ok(), Box::new(BufReader::new(reader))))
}
#[cfg(test)]
#[path = "item_reader_tests.rs"]
mod tests;

View file

@ -0,0 +1,166 @@
use super::*;
use std::io::Cursor;
/// Drain a receiver into the collected item texts.
///
/// Takes the receiver by value so it is dropped at the end, closing the
/// channel.
#[allow(clippy::needless_pass_by_value)]
fn drain(rx: SkimItemReceiver) -> Vec<String> {
let mut out = Vec::new();
while let Ok(batch) = rx.recv() {
for item in batch {
out.push(item.text().into_owned());
}
}
out
}
#[test]
fn of_bufread_disables_items_matching_disable_pattern() {
// `--disable-pattern` flags items whose text matches the regex; the reader
// sets `disabled()` on them (they later become unselectable).
let mut opts = crate::SkimOptions::default();
opts.disable_pattern = Some(regex::Regex::new("foo").unwrap());
let reader = SkimItemReader::new(SkimItemReaderOption::from_options(&opts));
let rx = reader.of_bufread(Cursor::new("foo\nbar\n"));
let mut disabled_by_text = std::collections::HashMap::new();
while let Ok(batch) = rx.recv() {
for item in batch {
disabled_by_text.insert(item.text().into_owned(), item.disabled());
}
}
assert_eq!(disabled_by_text.get("foo"), Some(&true));
assert_eq!(disabled_by_text.get("bar"), Some(&false));
}
#[test]
fn of_bufread_reads_newline_separated_items() {
let reader = SkimItemReader::default();
let rx = reader.of_bufread(Cursor::new(b"a\nb\nc\n".to_vec()));
assert_eq!(drain(rx), vec!["a", "b", "c"]);
}
#[test]
fn of_bufread_read0_splits_on_nul() {
let opt = SkimItemReaderOption::default().read0(true).build();
let reader = SkimItemReader::new(opt);
let rx = reader.of_bufread(Cursor::new(b"a\0b\0c\0".to_vec()));
assert_eq!(drain(rx), vec!["a", "b", "c"]);
}
#[test]
fn of_bufread_strips_ansi_when_enabled() {
let opt = SkimItemReaderOption::default().ansi(true).build();
let reader = SkimItemReader::new(opt);
let rx = reader.of_bufread(Cursor::new(b"\x1b[31mred\x1b[0m\n".to_vec()));
assert_eq!(drain(rx), vec!["red"]);
}
#[test]
fn of_bufread_with_custom_line_ending() {
let opt = SkimItemReaderOption::default().line_ending(b';').build();
let reader = SkimItemReader::new(opt);
let rx = reader.of_bufread(Cursor::new(b"a;b;c;".to_vec()));
assert_eq!(drain(rx), vec!["a", "b", "c"]);
}
#[test]
fn builder_setters_chain() {
// Exercise the remaining builder setters; build() returns self.
let opt = SkimItemReaderOption::default()
.buf_size(4096)
.ansi(false)
.delimiter(Regex::new(r"\s+").unwrap())
.with_nth(["1"].into_iter())
.transform_fields(Vec::new())
.nth(["2"].into_iter())
.matching_fields(Vec::new())
.show_error(true)
.build();
assert_eq!(opt.buf_size, 4096);
assert!(opt.show_error);
}
#[test]
fn from_options_maps_read0_and_ansi() {
let mut options = SkimOptions::default();
options.read0 = true;
options.ansi = true;
let opt = SkimItemReaderOption::from_options(&options);
assert_eq!(opt.line_ending, b'\0');
assert!(opt.use_ansi_color);
}
#[test]
fn invoke_runs_a_command() {
// The command runs through `shell_cmd` (`sh -c` / `cmd /c`); pick syntax that
// emits two newline-separated lines on each platform. The reader strips a
// trailing `\r`, so cmd.exe's CRLF output still yields bare "x"/"y".
let cmd = if cfg!(windows) {
"echo x& echo y"
} else {
"printf 'x\\ny\\n'"
};
let mut reader = SkimItemReader::default();
let (rx, _tx) = reader.invoke(cmd, Arc::new(AtomicUsize::new(0)));
assert_eq!(drain(rx), vec!["x", "y"]);
}
#[test]
fn invoke_with_show_error_redirects_stderr() {
// show_error routes the child's stderr into the item stream. On cmd.exe the
// redirect is written first so `echo` does not capture a trailing space.
let cmd = if cfg!(windows) {
"1>&2 echo oops"
} else {
"printf 'oops\\n' 1>&2"
};
let opt = SkimItemReaderOption::default().show_error(true).build();
let mut reader = SkimItemReader::new(opt);
let (rx, _tx) = reader.invoke(cmd, Arc::new(AtomicUsize::new(0)));
assert_eq!(drain(rx), vec!["oops"]);
}
#[test]
fn read0_false_restores_newline_ending() {
let opt = SkimItemReaderOption::default().read0(true).read0(false).build();
assert_eq!(opt.line_ending, b'\n');
}
#[test]
fn option_setter_replaces_options() {
// `SkimItemReader::option` swaps in a fresh option set.
let reader = SkimItemReader::default().option(SkimItemReaderOption::default().read0(true).build());
let rx = reader.of_bufread(Cursor::new(b"a\0b\0".to_vec()));
assert_eq!(drain(rx), vec!["a", "b"]);
}
#[test]
fn thread_pool_setters() {
// Both the chaining and the &mut variants accept a shared pool.
let pool = default_thread_pool();
let mut reader = SkimItemReader::default().with_thread_pool(pool.clone());
reader.set_thread_pool(pool);
let rx = reader.of_bufread(Cursor::new(b"x\ny\n".to_vec()));
assert_eq!(drain(rx), vec!["x", "y"]);
}
#[test]
fn of_bufread_skips_invalid_utf8_lines() {
// A line that is not valid UTF-8 is dropped; surrounding lines survive.
let reader = SkimItemReader::default();
let rx = reader.of_bufread(Cursor::new(b"ok\n\xff\xfe\nalso\n".to_vec()));
assert_eq!(drain(rx), vec!["ok", "also"]);
}
#[test]
fn of_bufread_applies_with_nth_transform() {
// with_nth selects the second whitespace field for display.
let opt = SkimItemReaderOption::default().with_nth(["2"].into_iter()).build();
let reader = SkimItemReader::new(opt);
let rx = reader.of_bufread(Cursor::new(b"alpha beta gamma\n".to_vec()));
// The selected field retains its trailing delimiter.
assert_eq!(drain(rx), vec!["beta "]);
}

489
src/helper/item_tests.rs Normal file
View file

@ -0,0 +1,489 @@
use super::*;
#[test]
fn test_strip_ansi() {
// Test basic ANSI color codes
// "\x1b[31mred\x1b[0m" has chars at positions: 0=ESC, 1=[, 2=3, 3=1, 4=m, 5=r, 6=e, 7=d, 8=ESC, 9=[, 10=0, 11=m
let (text, mapping) = strip_ansi("\x1b[31mred\x1b[0m");
assert_eq!(text, "red");
assert_eq!(mapping, vec![(5, 5), (6, 6), (7, 7)]);
let (text, mapping) = strip_ansi("\x1b[01;32mgreen\x1b[0m");
assert_eq!(text, "green");
assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10), (11, 11), (12, 12)]);
let (text, mapping) = strip_ansi("\x1b[01;34mblue\x1b[0m");
assert_eq!(text, "blue");
assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10), (11, 11)]);
// Test text without ANSI codes
let (text, mapping) = strip_ansi("plain text");
assert_eq!(text, "plain text");
assert_eq!(
mapping,
vec![
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9)
]
);
// Test multiple ANSI sequences
let (text, mapping) = strip_ansi("\x1b[31mred\x1b[0m and \x1b[32mgreen\x1b[0m");
assert_eq!(text, "red and green");
assert_eq!(
mapping,
vec![
(5, 5),
(6, 6),
(7, 7),
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16),
(22, 22),
(23, 23),
(24, 24),
(25, 25),
(26, 26)
]
);
// Test ANSI codes in the middle of text
let (text, mapping) = strip_ansi("be\x1b[01;34mf\x1b[0more");
assert_eq!(text, "before");
assert_eq!(mapping, vec![(0, 0), (1, 1), (10, 10), (15, 15), (16, 16), (17, 17)]);
// Test real ls --color output
let (text, mapping) = strip_ansi("\x1b[01;32mbench.sh\x1b[0m");
assert_eq!(text, "bench.sh");
assert_eq!(
mapping,
vec![
(8, 8),
(9, 9),
(10, 10),
(11, 11),
(12, 12),
(13, 13),
(14, 14),
(15, 15)
]
);
let (text, mapping) = strip_ansi("\x1b[01;34mbin\x1b[0m");
assert_eq!(text, "bin");
assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10)]);
// Test with multi-byte UTF-8 characters to verify byte vs char position difference
// "😀" is 4 bytes but 1 char - when followed by ANSI codes, byte and char positions diverge
let (text, mapping) = strip_ansi("😀\x1b[32mtext\x1b[0m");
assert_eq!(text, "😀text");
// Original: "😀\x1b[32mtext\x1b[0m"
// byte positions: 😀=0-3, \x1b=4, [=5, 3=6, 2=7, m=8, t=9, e=10, x=11, t=12, \x1b=13, [=14, 0=15, m=16
// char positions: 😀=0, \x1b=1, [=2, 3=3, 2=4, m=5, t=6, e=7, x=8, t=9, \x1b=10, [=11, 0=12, m=13
// After stripping: "😀text"
// stripped[0]='😀' -> (byte=0, char=0)
// stripped[1]='t' -> (byte=9, char=6) <- Here byte and char positions differ!
assert_eq!(mapping, vec![(0, 0), (9, 6), (10, 7), (11, 8), (12, 9)]);
}
#[test]
fn test_strip_ansi_osc_sequence_bel_terminated() {
// OSC sequence (ESC ]) terminated by BEL (\x07) is fully stripped.
let (text, _) = strip_ansi("\x1b]0;title\x07visible");
assert_eq!(text, "visible");
}
#[test]
fn test_strip_ansi_osc_sequence_st_terminated() {
// OSC sequence terminated by the ST string terminator (ESC \) is stripped.
let (text, _) = strip_ansi("\x1b]8;;http://example.com\x1b\\link");
assert_eq!(text, "link");
}
#[test]
fn test_strip_ansi_two_char_escape_sequences() {
// ESC ( / ESC ) charset-selection sequences consume the two-char prefix.
let (text, _) = strip_ansi("\x1b(Bplain");
assert_eq!(text, "plain");
let (text, _) = strip_ansi("\x1b)0plain");
assert_eq!(text, "plain");
}
#[test]
fn test_strip_ansi_unknown_escape_consumes_one_char() {
// An unrecognised escape (ESC followed by an unknown byte) drops that byte.
let (text, _) = strip_ansi("\x1bXdata");
assert_eq!(text, "data");
}
#[test]
fn test_strip_ansi_trailing_lone_escape() {
// A trailing ESC with nothing after it is dropped without panicking.
let (text, _) = strip_ansi("abc\x1b");
assert_eq!(text, "abc");
}
#[test]
fn test_ansi_matching_and_display() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
// Create an item with ANSI codes
let input = "\x1b[32mgreen\x1b[0m text";
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new(
input,
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// text() should return stripped text for matching
assert_eq!(item.text(), "green text");
// Verify we have ANSI info
assert!(item.ansi_info().is_some());
// Create a match context as if we matched "text" (positions 6-10 in stripped string)
let context = DisplayContext {
score: 100,
matches: Matches::CharRange(6, 10),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().fg(Color::Yellow),
};
// display() should map the match positions back to the original ANSI text
let line = item.display(context);
// The line should have the original ANSI codes intact
// We can't easily verify the exact ANSI codes in the output, but we can check
// that it's not empty and has multiple spans (original text + highlighted match)
assert!(!line.spans.is_empty());
}
#[test]
fn test_ansi_char_indices_mapping() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
// Create an item with ANSI codes: "😀\x1b[32mtext\x1b[0m"
let input = "😀\x1b[32mtext\x1b[0m";
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new(
input,
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// text() should return "😀text"
assert_eq!(item.text(), "😀text");
// Match indices 1,2 in stripped text (the 't' and 'e')
let context = DisplayContext {
score: 100,
matches: Matches::CharIndices(vec![1, 2]),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().fg(Color::Yellow),
};
// display() should map these to positions 6,7 in original text
let line = item.display(context);
assert!(!line.spans.is_empty());
}
#[test]
fn test_text_returns_stripped() {
use crate::SkimItem;
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Test with ANSI enabled
let item_ansi = DefaultSkimItem::new(
"\x1b[31mred\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
assert_eq!(
item_ansi.text(),
"red",
"text() should return stripped text when ANSI is enabled"
);
// Test with ANSI disabled
let item_no_ansi = DefaultSkimItem::new(
"\x1b[31mred\x1b[0m",
false, // ansi_enabled
&[],
&[],
&delimiter,
);
assert_eq!(
item_no_ansi.text(),
"?[31mred?[0m",
"text() should return text with ? when ANSI is disabled"
);
}
#[test]
fn test_highlighting_applied() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// Create display context with yellow background highlight for character 0 (the 'g')
let context = DisplayContext {
score: 100,
matches: Matches::CharIndices(vec![0]),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
// The line should have spans with highlighting
// At least one span should have the yellow background
let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow));
assert!(has_highlight, "Highlighted character should have yellow background");
// The green foreground from ANSI should be preserved in at least one span
let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green));
assert!(has_green_fg, "ANSI green foreground should be preserved");
}
#[test]
fn test_char_range_highlighting() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// Create display context with yellow background highlight for characters 1-3 ('re')
let context = DisplayContext {
score: 100,
matches: Matches::CharRange(1, 3),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
// Should have multiple spans: before, highlighted, after
assert!(line.spans.len() >= 2, "Should have multiple spans for highlighting");
// At least one span should have the yellow background (the highlighted portion)
let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow));
assert!(has_highlight, "Highlighted characters should have yellow background");
// The green foreground from ANSI should be preserved
let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green));
assert!(has_green_fg, "ANSI green foreground should be preserved");
}
#[test]
fn test_byte_range_highlighting() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
);
// Create display context with yellow background highlight for bytes 1-3 ('re' in stripped text)
let context = DisplayContext {
score: 100,
matches: Matches::ByteRange(1, 3),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
// Should have multiple spans for highlighting
assert!(!line.spans.is_empty(), "Should have spans");
// At least one span should have the yellow background (the highlighted portion)
let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow));
assert!(has_highlight, "Highlighted bytes should have yellow background");
// The green foreground from ANSI should be preserved
let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green));
assert!(has_green_fg, "ANSI green foreground should be preserved");
}
#[test]
fn test_matching_with_ansi_basic() {
use crate::SkimItem;
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Create item with ANSI codes: "\x1b[32mgreen_text\x1b[0m"
let item = DefaultSkimItem::new(
"\x1b[32mgreen_text\x1b[0m",
true, // ansi_enabled
&[],
&[], // no matching fields restriction
&delimiter,
);
// text() should return stripped text "green_text"
assert_eq!(item.text(), "green_text");
// With no matching_fields, get_matching_ranges should return None (match whole text)
assert!(item.get_matching_ranges().is_none());
// Verify the stripped_text and ansi_info are populated correctly
assert!(item.stripped_text().is_some());
assert!(item.ansi_info().is_some());
assert_eq!(item.stripped_text().unwrap(), "green_text");
}
#[test]
fn test_null_delimiter_with_matching_fields() {
use crate::SkimItem;
use crate::field::FieldRange;
use regex::Regex;
// Test with null byte delimiter and matching_fields
let delimiter = Regex::new("\x00").unwrap();
let text = "a\x00b\x00c";
// Create item with matching field 2
let item = DefaultSkimItem::new(
text,
false, // no ansi
&[], // no transform fields
&[FieldRange::Single(2)], // match field 2
&delimiter,
);
// text() should return text with null bytes stripped for display
assert_eq!(item.text(), "abc");
// get_matching_ranges should return the range for field 2 in the stripped text
let ranges = item.get_matching_ranges().expect("Should have matching ranges");
assert_eq!(ranges.len(), 1, "Should have one matching range");
// Field 2 is "b" which is at position 1 in the stripped text "abc"
assert_eq!(ranges[0], (1, 2), "Field 2 should be at position 1-2 in stripped text");
// Verify the substring matches what we expect
let stripped_text = item.text();
let field_text = &stripped_text[ranges[0].0..ranges[0].1];
assert_eq!(field_text, "b", "Field text should be 'b'");
}
#[test]
fn test_default_skim_item_from_string_and_display_text() {
let item = DefaultSkimItem::from("plain text".to_string());
assert_eq!(item.get_display_text(), "plain text");
assert_eq!(item.text(), "plain text");
}
#[test]
fn test_transform_fields_with_ansi_enabled() {
use regex::Regex;
// Both a transform field and ANSI enabled exercises the (true, true) arm.
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new(
"\x1b[32mone\x1b[0m two three",
true,
&[FieldRange::Single(2)],
&[],
&delimiter,
);
// The display text is the second field.
assert!(item.text().contains("two"));
}
#[test]
fn test_matching_fields_with_ansi_uses_stripped_text() {
use regex::Regex;
// ANSI enabled with matching fields makes range computation use stripped text.
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new(
"\x1b[32mone\x1b[0m two",
true,
&[],
&[FieldRange::Single(2)],
&delimiter,
);
assert_eq!(item.text(), "one two");
let ranges = item.get_matching_ranges().expect("matching ranges present");
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_display_ansi_item_with_no_matches() {
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::Style;
use regex::Regex;
// An ANSI-enabled item displayed with `Matches::None` keeps the parsed
// ANSI spans unchanged (the `Matches::None` arm of the ANSI branch).
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new("\x1b[31mred\x1b[0m text", true, &[], &[], &delimiter);
let context = DisplayContext {
score: 0,
matches: Matches::None,
container_width: 80,
base_style: Style::default(),
matched_style: Style::default(),
};
let line = item.display(context);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect::<String>();
assert!(text.contains("red"));
assert!(text.contains("text"));
}

View file

@ -72,6 +72,7 @@ impl Selector for DefaultSkimSelector {
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
@ -115,4 +116,16 @@ mod tests {
assert!(selector.should_select(2, &"c"));
assert!(!selector.should_select(3, &"d"));
}
#[test]
pub fn disabled_item_is_never_selected() {
use crate::helper::item::DefaultSkimItem;
use regex::Regex;
// A disabled item is rejected even when it would otherwise match first_n.
let selector = DefaultSkimSelector::default().first_n(10);
let mut item = DefaultSkimItem::new("anything", false, &[], &[], &Regex::new(" ").unwrap());
item.disable();
assert!(!selector.should_select(0, &item));
}
}

View file

@ -604,3 +604,8 @@ impl ValueEnum for RankCriteria {
})
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
#[path = "item_tests.rs"]
mod tests;

218
src/item_tests.rs Normal file
View file

@ -0,0 +1,218 @@
use super::*;
fn item(text: &str) -> Arc<dyn SkimItem> {
Arc::new(text.to_string())
}
fn matched(text: &str, index: i32, score: i32) -> MatchedItem {
let rank = Rank {
score,
index,
..Default::default()
};
MatchedItem::new(item(text), rank, None, &RankBuilder::default())
}
#[test]
fn rank_builder_inserts_score_criterion() {
// Score is implicit and prepended when missing.
let rb = RankBuilder::new(vec![RankCriteria::Begin]);
assert_eq!(rb.criteria().first(), Some(&RankCriteria::Score));
// Already present -> not duplicated.
let rb = RankBuilder::new(vec![RankCriteria::NegScore, RankCriteria::Begin]);
assert_eq!(rb.criteria().first(), Some(&RankCriteria::NegScore));
assert!(!rb.criteria().contains(&RankCriteria::Score));
}
#[test]
fn build_rank_records_offsets_and_pathname() {
let rb = RankBuilder::default();
let rank = rb.build_rank(50, 4, 7, "src/lib/foo.rs");
assert_eq!(rank.score, 50);
assert_eq!(rank.begin, 4);
assert_eq!(rank.end, 7);
assert_eq!(rank.length, i32::try_from("src/lib/foo.rs".len()).unwrap());
// path_name_offset points just past the last separator.
assert_eq!(rank.path_name_offset, i32::try_from("src/lib/".len()).unwrap());
}
#[test]
fn sort_key_flips_score_sign() {
let rank = Rank {
score: 10,
begin: 2,
end: 5,
..Default::default()
};
let key = rank.sort_key(&[RankCriteria::Score, RankCriteria::Begin, RankCriteria::End]);
assert_eq!(key[0], -10);
assert_eq!(key[1], 2);
assert_eq!(key[2], 5);
}
#[test]
fn matched_item_ordering_prefers_higher_score() {
let high = matched("a", 0, 100);
let low = matched("b", 1, 10);
// Higher score sorts first (smaller sort key).
assert!(high < low);
}
#[test]
fn sorted_merge_handles_empty_inputs() {
let a = vec![matched("a", 0, 10)];
assert_eq!(MatchedItem::sorted_merge(a.clone(), vec![]).len(), 1);
assert_eq!(MatchedItem::sorted_merge(vec![], a.clone()).len(), 1);
}
#[test]
fn sorted_merge_interleaves_in_order() {
let existing = vec![matched("a", 0, 100), matched("c", 2, 10)];
let incoming = vec![matched("b", 1, 50)];
let merged = MatchedItem::sorted_merge(existing, incoming);
let scores: Vec<i32> = merged.iter().map(|m| m.rank.score).collect();
assert_eq!(scores, vec![100, 50, 10]);
}
#[test]
fn sorted_merge_prepends_when_incoming_all_better() {
// Incoming all rank ahead of existing → fast prepend path.
let existing = vec![matched("c", 2, 10)];
let incoming = vec![matched("a", 0, 100), matched("b", 1, 50)];
let merged = MatchedItem::sorted_merge(existing, incoming);
let scores: Vec<i32> = merged.iter().map(|m| m.rank.score).collect();
assert_eq!(scores, vec![100, 50, 10]);
}
#[test]
fn sorted_merge_appends_when_existing_all_better() {
// Existing all rank ahead of incoming → fast append path (existing.last() <= incoming.first()).
let existing = vec![matched("a", 0, 100), matched("b", 1, 50)];
let incoming = vec![matched("c", 2, 10)];
let merged = MatchedItem::sorted_merge(existing, incoming);
let scores: Vec<i32> = merged.iter().map(|m| m.rank.score).collect();
assert_eq!(scores, vec![100, 50, 10]);
}
#[test]
fn sorted_merge_drains_incoming_tail() {
// Interleave so the merge loop runs and `existing` exhausts first,
// leaving an incoming tail to drain via the (None, _) arm.
let existing = vec![matched("a", 0, 100), matched("c", 2, 40)];
let incoming = vec![matched("b", 1, 50), matched("d", 3, 10)];
let merged = MatchedItem::sorted_merge(existing, incoming);
let scores: Vec<i32> = merged.iter().map(|m| m.rank.score).collect();
assert_eq!(scores, vec![100, 50, 40, 10]);
}
#[test]
fn matched_item_debug_includes_text_and_rank() {
let s = format!("{:?}", matched("hello", 3, 42));
assert!(s.contains("MatchedItem"));
assert!(s.contains("hello"));
}
#[test]
fn merge_into_sorted_small_insert() {
let mut existing = vec![matched("a", 0, 100), matched("c", 2, 10)];
MatchedItem::merge_into_sorted(&mut existing, vec![matched("b", 1, 50)]);
let scores: Vec<i32> = existing.iter().map(|m| m.rank.score).collect();
assert_eq!(scores, vec![100, 50, 10]);
}
#[test]
fn merge_into_sorted_large_uses_backwards_merge() {
// Force the > SMALL_INSERT_THRESHOLD branch with interleaving order.
let mut existing: Vec<MatchedItem> = (0i32..300).map(|i| matched("x", i, 1000 - i * 2)).collect();
let incoming: Vec<MatchedItem> = (0i32..300).map(|i| matched("y", 1000 + i, 1000 - i * 2 - 1)).collect();
let total = existing.len() + incoming.len();
MatchedItem::merge_into_sorted(&mut existing, incoming);
assert_eq!(existing.len(), total);
// Result must be sorted ascending by sort key.
assert!(existing.windows(2).all(|w| w[0] <= w[1]));
}
#[test]
fn merge_into_sorted_large_with_incoming_holding_best() {
// >256 incoming forces the backwards in-place merge. Give incoming the
// highest scores so `existing` exhausts first, leaving an incoming run
// that is block-copied to the front (the `bi > 0` branch).
let mut existing: Vec<MatchedItem> = (0i32..300).map(|i| matched("x", i, 500 - i * 2)).collect();
let incoming: Vec<MatchedItem> = (0i32..300).map(|i| matched("y", 1000 + i, 2000 - i * 2)).collect();
let total = existing.len() + incoming.len();
MatchedItem::merge_into_sorted(&mut existing, incoming);
assert_eq!(existing.len(), total);
assert!(existing.windows(2).all(|w| w[0] <= w[1]));
// The top-scoring item came from the incoming batch.
assert_eq!(existing[0].rank.score, 2000);
}
#[test]
fn downcast_item_recovers_concrete_type() {
let m = matched("hello", 0, 1);
let s: Option<&String> = m.downcast_item::<String>();
assert_eq!(s.map(String::as_str), Some("hello"));
}
#[test]
fn item_pool_append_take_and_counters() {
let pool = ItemPool::new();
assert!(pool.is_empty());
pool.append(vec![item("a"), item("b"), item("c")]);
assert_eq!(pool.len(), 3);
assert_eq!(pool.num_not_taken(), 3);
assert_eq!(pool.num_taken(), 0);
let taken = pool.take();
assert_eq!(taken.len(), 3);
assert_eq!(pool.num_taken(), 3);
assert_eq!(pool.num_not_taken(), 0);
// A second take yields nothing new.
assert!(pool.take().is_empty());
}
#[test]
fn item_pool_reset_replays_items() {
let pool = ItemPool::new();
pool.append(vec![item("a"), item("b")]);
let _ = pool.take();
assert_eq!(pool.num_not_taken(), 0);
pool.reset();
assert_eq!(pool.num_not_taken(), 2);
assert_eq!(pool.take().len(), 2);
}
#[test]
fn item_pool_clear_empties_everything() {
let pool = ItemPool::new();
pool.append(vec![item("a"), item("b")]);
pool.clear();
assert!(pool.is_empty());
assert_eq!(pool.num_taken(), 0);
}
#[test]
fn item_pool_reserves_header_lines() {
let mut options = crate::SkimOptions::default();
options.header_lines = 2;
let pool = ItemPool::from_options(&options);
pool.append(vec![item("h1"), item("h2"), item("body1"), item("body2")]);
let reserved = pool.reserved();
assert_eq!(reserved.len(), 2);
assert_eq!(reserved[0].text(), "h1");
// Reserved header items are not part of the main matchable pool.
assert_eq!(pool.len(), 2);
}
#[test]
fn item_pool_tac_reverses_take_order() {
let mut options = crate::SkimOptions::default();
options.tac = true;
let pool = ItemPool::from_options(&options);
pool.append(vec![item("a"), item("b"), item("c")]);
let taken: Vec<String> = pool.take().iter().map(|i| i.text().into_owned()).collect();
assert_eq!(taken, vec!["c", "b", "a"]);
}

View file

@ -19,6 +19,7 @@
//! ["awk", "bash", "csh", "dash", "fish", "ksh", "zsh"]
//! ).unwrap();
//! ```
#![cfg_attr(coverage, feature(coverage_attribute))]
#[macro_use]
extern crate log;
@ -39,7 +40,7 @@ use ratatui::text::{Line, Span};
pub use crate::engine::fuzzy::FuzzyAlgorithm;
pub use crate::item::RankCriteria;
pub use crate::options::SkimOptions;
pub use crate::output::SkimOutput;
pub use crate::output::{BinOptions, SkimOutput};
pub use crate::skim::*;
pub use crate::skim_item::SkimItem;
use crate::tui::Size;
@ -412,3 +413,7 @@ pub trait Selector {
pub type SkimItemSender = kanal::Sender<Vec<Arc<dyn SkimItem>>>;
/// Receiver for streaming items to skim
pub type SkimItemReceiver = kanal::Receiver<Vec<Arc<dyn SkimItem>>>;
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;

99
src/lib_tests.rs Normal file
View file

@ -0,0 +1,99 @@
use super::*;
/// The concatenated text of every span in a line.
fn line_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
/// The concatenated text of only the highlighted spans.
fn highlighted_text(line: &Line, matched: Style) -> String {
line.spans
.iter()
.filter(|s| s.style == matched)
.map(|s| s.content.as_ref())
.collect()
}
fn ctx(matches: Matches) -> DisplayContext {
let matched_style = Style::default().fg(ratatui::style::Color::Red);
DisplayContext {
score: 0,
matches,
container_width: 80,
base_style: Style::default(),
matched_style,
}
}
#[test]
fn to_line_char_indices_highlights_individual_chars() {
let context = ctx(Matches::CharIndices(vec![0, 2]));
let matched = context.base_style.patch(context.matched_style);
let line = context.clone().to_line(Cow::Borrowed("abcd"));
assert_eq!(line_text(&line), "abcd");
assert_eq!(highlighted_text(&line, matched), "ac");
}
#[test]
fn to_line_char_range_highlights_span() {
let context = ctx(Matches::CharRange(1, 3));
let matched = context.base_style.patch(context.matched_style);
let line = context.clone().to_line(Cow::Borrowed("abcd"));
assert_eq!(line_text(&line), "abcd");
assert_eq!(highlighted_text(&line, matched), "bc");
}
#[test]
fn to_line_byte_range_highlights_span() {
let context = ctx(Matches::ByteRange(1, 3));
let matched = context.base_style.patch(context.matched_style);
let line = context.clone().to_line(Cow::Borrowed("abcd"));
assert_eq!(line_text(&line), "abcd");
assert_eq!(highlighted_text(&line, matched), "bc");
}
#[test]
fn to_line_none_has_no_highlight() {
let context = ctx(Matches::None);
let line = context.to_line(Cow::Borrowed("abcd"));
assert_eq!(line_text(&line), "abcd");
assert_eq!(line.spans.len(), 1);
}
#[test]
fn typos_from_usize() {
assert_eq!(Typos::from(0), Typos::Disabled);
assert_eq!(Typos::from(3), Typos::Fixed(3));
}
#[test]
fn match_result_range_char_indices_variants() {
let byte = MatchResult {
rank: Rank::default(),
matched_range: MatchRange::ByteRange(1, 3),
};
assert_eq!(byte.range_char_indices("abcd"), vec![1, 2]);
let char_range = MatchResult {
rank: Rank::default(),
matched_range: MatchRange::CharRange(1, 3),
};
assert_eq!(char_range.range_char_indices("abcd"), vec![1, 2]);
let chars = MatchResult {
rank: Rank::default(),
matched_range: MatchRange::Chars(vec![0, 3]),
};
assert_eq!(chars.range_char_indices("abcd"), vec![0, 3]);
}
#[test]
fn as_any_downcasts_mutably() {
let mut value: String = "hello".to_string();
// Immutable downcast via the blanket `AsAny` impl.
assert!(value.as_any().downcast_ref::<String>().is_some());
// Mutable downcast exercises `as_any_mut`.
let s = value.as_any_mut().downcast_mut::<String>().unwrap();
s.push_str(" world");
assert_eq!(value, "hello world");
}

View file

@ -367,6 +367,7 @@ Example:
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;

View file

@ -402,3 +402,96 @@ impl Matcher {
}
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::Rank;
use crate::item::RankBuilder;
use crate::options::SkimOptionsBuilder;
fn matched(text: &str, index: i32) -> MatchedItem {
MatchedItem::new(
Arc::new(text.to_string()),
Rank {
index,
..Default::default()
},
None,
&RankBuilder::default(),
)
}
#[test]
fn from_options_exposes_case_and_factory() {
let options = SkimOptionsBuilder::default()
.case(CaseMatching::Ignore)
.build()
.unwrap();
let matcher = Matcher::from_options(&options);
assert_eq!(matcher.case_matching(), CaseMatching::Ignore);
// The factory builds a working engine.
let engine = matcher.engine_factory().create_engine("foo");
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn create_engine_factory_builds_fuzzy_engine() {
let options = SkimOptions::default();
let factory = Matcher::create_engine_factory(&options);
let engine = factory.create_engine("fb");
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn create_engine_factory_regex_with_normalize() {
let options = SkimOptionsBuilder::default()
.regex(true)
.normalize(true)
.build()
.unwrap();
let (factory, _rank) = Matcher::create_engine_factory_with_builder(&options);
let engine = factory.create_engine("ba.");
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn merge_worker_results_replace_sorts() {
let processed = SpinLock::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);
assert!(needs_render.load(Ordering::Relaxed));
let guard = processed.lock();
let items = &guard.as_ref().unwrap().items;
assert_eq!(items.len(), 2);
}
#[test]
fn merge_worker_results_append_no_sort_extends_existing() {
let processed = SpinLock::new(None);
let needs_render = AtomicBool::new(false);
// First append establishes the existing list.
merge_worker_results(
vec![vec![matched("a", 0)]],
true,
&processed,
MergeStrategy::Append,
&needs_render,
);
// Second append with no_sort extends the existing list in place.
merge_worker_results(
vec![vec![matched("b", 1)]],
true,
&processed,
MergeStrategy::Append,
&needs_render,
);
let guard = processed.lock();
assert_eq!(guard.as_ref().unwrap().items.len(), 2);
}
}

View file

@ -1316,19 +1316,49 @@ impl SkimOptions {
///
/// Panics if the process was invoked with no arguments (which should never happen in practice).
pub fn from_env() -> Result<Self, clap::Error> {
use clap::Parser;
use std::env;
let mut args = Vec::new();
let prog = env::args()
.next()
.expect("there should be at least one arg: the application name");
let options_file_content = env::var("SKIM_OPTIONS_FILE").ok().and_then(|f| std::fs::read(f).ok());
let default_options = env::var("SKIM_DEFAULT_OPTIONS").ok();
let default_command = env::var("SKIM_DEFAULT_COMMAND").ok();
args.push(
env::args()
.next()
.expect("there should be at least one arg: the application name"),
);
if let Ok(opts_file) = env::var("SKIM_OPTIONS_FILE")
&& let Ok(content) = std::fs::read(opts_file)
{
Self::merge_args_and_parse(
prog,
options_file_content.as_deref(),
default_options.as_deref(),
env::args().skip(1),
default_command,
)
}
/// Build [`SkimOptions`] from explicitly provided sources, mirroring how
/// [`from_env`](Self::from_env) assembles them but without touching the
/// process environment — which makes it unit-testable and platform-agnostic.
///
/// Precedence (lowest to highest): `SKIM_OPTIONS_FILE` contents, then
/// `SKIM_DEFAULT_OPTIONS`, then the real CLI args. `default_command`
/// (`SKIM_DEFAULT_COMMAND`) only fills `cmd` when no `--cmd` was given,
/// falling back to [`crate::SKIM_DEFAULT_COMMAND`].
///
/// # Errors
///
/// Returns a [`clap::Error`] if argument parsing fails.
#[cfg(feature = "cli")]
pub(crate) fn merge_args_and_parse(
prog: String,
options_file_content: Option<&[u8]>,
default_options: Option<&str>,
cli_args: impl IntoIterator<Item = String>,
default_command: Option<String>,
) -> Result<Self, clap::Error> {
use clap::Parser;
let mut args = vec![prog];
if let Some(content) = options_file_content {
let mut in_comment = false;
let mut pending_comment = false;
let without_comments = content
@ -1362,20 +1392,14 @@ impl SkimOptions {
let parsed = String::from_utf8_lossy(&without_comments);
args.extend(shlex::split(&parsed).unwrap_or_default());
}
args.extend(
env::var("SKIM_DEFAULT_OPTIONS")
.ok()
.and_then(|val| shlex::split(&val))
.unwrap_or_default(),
);
for arg in env::args().skip(1) {
args.push(arg);
}
args.extend(default_options.and_then(shlex::split).unwrap_or_default());
args.extend(cli_args);
Self::try_parse_from(args).map(|mut opts| {
opts.cmd.get_or_insert(
std::env::var("SKIM_DEFAULT_COMMAND").unwrap_or(crate::SKIM_DEFAULT_COMMAND.to_string()),
);
if opts.cmd.is_none() {
opts.cmd = Some(default_command.unwrap_or_else(|| crate::SKIM_DEFAULT_COMMAND.to_string()));
}
opts
})
}
@ -1413,3 +1437,7 @@ macro_rules! feature_flag {
}
#[allow(unused_imports)]
pub(crate) use feature_flag;
#[cfg(test)]
#[path = "options_tests.rs"]
mod tests;

289
src/options_tests.rs Normal file
View file

@ -0,0 +1,289 @@
//! Unit tests for [`super::SkimOptions`] — primarily the `build()` finalizer
//! and history initialization, which apply defaults and cross-option rules.
use super::*;
use crate::item::RankCriteria;
use crate::tui::statusline::InfoDisplay;
/// Helper: invoke the env-free arg merger with `prog = "sk"` and no real CLI args.
fn merge(
options_file_content: Option<&[u8]>,
default_options: Option<&str>,
default_command: Option<&str>,
) -> SkimOptions {
SkimOptions::merge_args_and_parse(
"sk".to_string(),
options_file_content,
default_options,
std::iter::empty(),
default_command.map(str::to_string),
)
.expect("options should parse")
}
#[test]
fn merge_uses_skim_default_command_when_no_cmd_flag() {
// SKIM_DEFAULT_COMMAND fills `cmd` when neither --cmd nor a pipe is given.
let opts = merge(None, None, Some("echo hello"));
assert_eq!(opts.cmd.as_deref(), Some("echo hello"));
}
#[test]
fn merge_falls_back_to_builtin_default_command() {
// With SKIM_DEFAULT_COMMAND unset, the built-in default is used.
let opts = merge(None, None, None);
assert_eq!(opts.cmd.as_deref(), Some(crate::SKIM_DEFAULT_COMMAND));
}
#[test]
fn merge_explicit_cmd_flag_overrides_default_command() {
// An explicit --cmd wins over SKIM_DEFAULT_COMMAND.
let opts = SkimOptions::merge_args_and_parse(
"sk".to_string(),
None,
Some("--cmd 'echo flag'"),
std::iter::empty(),
Some("echo env".to_string()),
)
.expect("options should parse");
assert_eq!(opts.cmd.as_deref(), Some("echo flag"));
}
#[test]
fn merge_applies_skim_default_options() {
// SKIM_DEFAULT_OPTIONS is shlex-split and merged into the args.
let opts = merge(None, Some("--prompt 'XXX '"), None);
assert_eq!(opts.prompt, "XXX ");
}
#[test]
fn merge_applies_options_file_and_strips_comments() {
// A full-line `# Preview` comment and a trailing `# Preview window` comment
// are removed; the surviving flags must still parse cleanly.
let content = b"# Preview\n\
--preview 'echo {}'\n\
--preview-window 'left:30%' # Preview window\n\
--prompt '>> '\n";
let opts = merge(Some(content), None, None);
assert_eq!(opts.preview.as_deref(), Some("echo {}"));
assert_eq!(opts.prompt, ">> ");
}
#[test]
fn merge_options_file_comment_stripper_is_not_quote_aware() {
// Known limitation (matches historical behavior): the `#` stripper runs
// before shlex and does not understand quotes, so a `#` inside a quoted
// value starts a comment. `'## '` therefore collapses to `'# '`.
let opts = merge(Some(b"--prompt '## '\n"), None, None);
assert_eq!(opts.prompt, "# ");
}
#[test]
fn merge_precedence_cli_args_override_default_options() {
// CLI args come last, so they win over SKIM_DEFAULT_OPTIONS.
let opts = SkimOptions::merge_args_and_parse(
"sk".to_string(),
None,
Some("--prompt 'from-env '"),
["--prompt".to_string(), "from-cli ".to_string()],
None,
)
.expect("options should parse");
assert_eq!(opts.prompt, "from-cli ");
}
#[test]
fn build_no_height_forces_full_height() {
let opts = SkimOptions {
no_height: true,
height: String::from("40%"),
..Default::default()
}
.build();
assert_eq!(opts.height, "100%");
}
#[test]
fn build_multiline_default_separator() {
let opts = SkimOptions {
multiline: Some(None),
read0: false,
..Default::default()
}
.build();
assert_eq!(opts.multiline, Some(Some(String::from("\\n"))));
}
#[test]
fn build_multiline_read0_uses_newline_separator() {
let opts = SkimOptions {
multiline: Some(None),
read0: true,
..Default::default()
}
.build();
assert_eq!(opts.multiline, Some(Some(String::from("\n"))));
}
#[test]
fn build_reverse_sets_reverse_layout() {
let opts = SkimOptions {
reverse: true,
..Default::default()
}
.build();
assert_eq!(opts.layout, TuiLayout::Reverse);
}
#[test]
fn build_no_scrollbar_clears_scrollbar() {
let opts = SkimOptions {
no_scrollbar: true,
scrollbar: String::from("|"),
..Default::default()
}
.build();
assert!(opts.scrollbar.is_empty());
}
#[test]
fn build_inline_info_sets_inline_display() {
let opts = SkimOptions {
inline_info: true,
..Default::default()
}
.build();
assert_eq!(opts.info.display, InfoDisplay::Inline);
assert!(opts.info.separator.is_some());
}
#[test]
fn build_no_info_hides_info() {
let opts = SkimOptions {
no_info: true,
..Default::default()
}
.build();
assert_eq!(opts.info.display, InfoDisplay::Hidden);
assert!(opts.info.separator.is_none());
}
#[test]
fn build_no_typos_disables_typos() {
let opts = SkimOptions {
no_typos: true,
..Default::default()
}
.build();
assert_eq!(opts.typos, Typos::Disabled);
}
#[test]
fn build_no_border_forces_border_off() {
let opts = SkimOptions {
no_border: true,
..Default::default()
}
.build();
assert!(matches!(opts.border, BorderType::ForceOff));
}
#[test]
fn build_filter_populates_query_when_absent() {
let opts = SkimOptions {
filter: Some(String::from("needle")),
query: None,
..Default::default()
}
.build();
assert_eq!(opts.query.as_deref(), Some("needle"));
}
#[test]
fn build_filter_does_not_override_existing_query() {
let opts = SkimOptions {
filter: Some(String::from("needle")),
query: Some(String::from("explicit")),
..Default::default()
}
.build();
assert_eq!(opts.query.as_deref(), Some("explicit"));
}
#[test]
fn build_scheme_path_adjusts_tiebreak() {
let opts = SkimOptions {
scheme: Some(MatchScheme::Path),
..Default::default()
}
.build();
assert!(opts.last_match);
assert_eq!(opts.tiebreak.first(), Some(&RankCriteria::Score));
assert!(opts.tiebreak.contains(&RankCriteria::PathName));
}
#[test]
fn build_scheme_history_prepends_index() {
let opts = SkimOptions {
scheme: Some(MatchScheme::History),
..Default::default()
}
.build();
assert_eq!(opts.tiebreak.first(), Some(&RankCriteria::Index));
}
#[test]
fn build_default_keymap_is_populated() {
let opts = SkimOptions::default().build();
assert!(!opts.keymap.is_empty());
}
#[test]
fn init_histories_reads_files() {
let dir = std::env::temp_dir();
let pid = std::process::id();
let qpath = dir.join(format!("skim_opt_test_query_{pid}.txt"));
let cpath = dir.join(format!("skim_opt_test_cmd_{pid}.txt"));
std::fs::write(&qpath, "q1\nq2\n").unwrap();
std::fs::write(&cpath, "c1\nc2\n").unwrap();
let mut opts = SkimOptions {
history_file: Some(qpath.to_string_lossy().into_owned()),
cmd_history_file: Some(cpath.to_string_lossy().into_owned()),
..Default::default()
};
opts.init_histories();
assert!(opts.query_history.iter().any(|l| l == "q1"));
assert!(opts.query_history.iter().any(|l| l == "q2"));
assert!(opts.cmd_history.iter().any(|l| l == "c1"));
assert!(opts.cmd_history.iter().any(|l| l == "c2"));
let _ = std::fs::remove_file(&qpath);
let _ = std::fs::remove_file(&cpath);
}
#[test]
fn build_history_file_adds_history_keybindings() {
let dir = std::env::temp_dir();
let pid = std::process::id();
let qpath = dir.join(format!("skim_opt_test_histbind_{pid}.txt"));
std::fs::write(&qpath, "old query\n").unwrap();
let opts = SkimOptions {
history_file: Some(qpath.to_string_lossy().into_owned()),
..Default::default()
}
.build();
assert!(
opts.keymap
.contains_key(&KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL))
);
assert!(
opts.keymap
.contains_key(&KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL))
);
let _ = std::fs::remove_file(&qpath);
}

View file

@ -1,5 +1,11 @@
use std::io::{self, Write};
use derive_builder::Builder;
use crate::item::MatchedItem;
use crate::options::SkimOptions;
use crate::tui::Event;
use crate::tui::event::Action;
/// Output from running skim, containing the final selection and state
#[derive(Debug)]
@ -31,3 +37,301 @@ pub struct SkimOutput {
/// The header
pub header: String,
}
impl SkimOutput {
/// Serialize this output to `out` according to the CLI output options.
///
/// This is the formatting half of skim's output and is intentionally
/// independent of stdout so it can be exercised by unit tests: the binary
/// passes a locked, buffered stdout, while tests pass a `Vec<u8>`.
///
/// # Errors
///
/// Returns any [`io::Error`] produced while writing to `out`.
pub fn write_output<W: Write>(&self, out: &mut W, opts: &BinOptions) -> io::Result<()> {
if let Some(ref output_format) = opts.output_format {
write!(
out,
"{}{}",
crate::printf(
output_format,
&opts.delimiter,
&opts.replstr,
&self.selected_items.iter(),
&self.current,
&self.query,
&self.cmd,
false
),
opts.output_ending
)?;
return Ok(());
}
if opts.print_query {
write!(out, "{}{}", self.query, opts.output_ending)?;
}
if opts.print_cmd {
write!(out, "{}{}", self.cmd, opts.output_ending)?;
}
if opts.print_header {
write!(out, "{}{}", self.header, opts.output_ending)?;
}
if opts.print_current {
if let Some(ref current) = self.current {
write!(out, "{}{}", current.output(), opts.output_ending)?;
} else {
write!(out, "{}", opts.output_ending)?;
}
}
if let Event::Action(Action::Accept(Some(accept_key))) = &self.final_event {
write!(out, "{}{}", accept_key, opts.output_ending)?;
}
for item in &self.selected_items {
if opts.strip_ansi {
write!(
out,
"{}{}",
crate::helper::item::strip_ansi(&item.output()).0,
opts.output_ending
)?;
} else {
write!(out, "{}{}", item.output(), opts.output_ending)?;
}
if opts.print_score {
write!(out, "{}{}", item.rank.score, opts.output_ending)?;
}
}
Ok(())
}
}
/// Options controlling how a [`SkimOutput`] is serialized to the terminal.
///
/// These mirror the CLI's output-related flags (`--print-query`, `--print0`,
/// `--print-score`, …) and are derived from [`SkimOptions`] via
/// [`BinOptions::from_opts`].
#[derive(Builder)]
#[allow(missing_docs, clippy::struct_excessive_bools)]
pub struct BinOptions {
output_ending: String,
print_query: bool,
print_cmd: bool,
print_score: bool,
print_header: bool,
print_current: bool,
strip_ansi: bool,
output_format: Option<String>,
delimiter: regex::Regex,
replstr: String,
}
impl BinOptions {
/// Build the output options from the parsed [`SkimOptions`].
#[must_use]
pub fn from_opts(opts: &SkimOptions) -> Self {
Self {
print_query: opts.print_query,
print_cmd: opts.print_cmd,
print_score: opts.print_score,
print_header: opts.print_header,
print_current: opts.print_current,
output_ending: String::from(if opts.print0 { "\0" } else { "\n" }),
strip_ansi: opts.ansi && !opts.no_strip_ansi,
output_format: opts.output_format.clone(),
delimiter: opts.delimiter.clone(),
replstr: opts.replstr.clone(),
}
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent};
use super::*;
use crate::item::{MatchedItem, RankBuilder};
use crate::{Rank, SkimItem};
fn matched(text: &str, score: i32) -> MatchedItem {
let item: Arc<dyn SkimItem> = Arc::new(text.to_string());
let rank = Rank {
score,
..Default::default()
};
MatchedItem::new(item, rank, None, &RankBuilder::default())
}
fn output_with(items: Vec<MatchedItem>, final_event: Event) -> SkimOutput {
SkimOutput {
final_event,
is_abort: false,
final_key: KeyEvent::new(KeyCode::Null, crossterm::event::KeyModifiers::NONE),
query: "qry".to_string(),
cmd: "cmd".to_string(),
selected_items: items,
current: None,
header: "hdr".to_string(),
}
}
fn opts() -> BinOptions {
BinOptions::from_opts(&SkimOptions::default())
}
fn render(out: &SkimOutput, opts: &BinOptions) -> String {
let mut buf = Vec::new();
out.write_output(&mut buf, opts).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn writes_selected_items_newline_separated() {
let out = output_with(
vec![matched("a", 0), matched("b", 0)],
Event::Action(Action::Accept(None)),
);
assert_eq!(render(&out, &opts()), "a\nb\n");
}
#[test]
fn print0_uses_nul_ending() {
let mut o = opts();
o.output_ending = "\0".to_string();
let out = output_with(
vec![matched("a", 0), matched("b", 0)],
Event::Action(Action::Accept(None)),
);
assert_eq!(render(&out, &o), "a\0b\0");
}
#[test]
fn print_query_cmd_header_precede_items_in_order() {
let mut o = opts();
o.print_query = true;
o.print_cmd = true;
o.print_header = true;
let out = output_with(vec![matched("a", 0)], Event::Action(Action::Accept(None)));
assert_eq!(render(&out, &o), "qry\ncmd\nhdr\na\n");
}
#[test]
fn print_current_writes_blank_line_when_no_current() {
let mut o = opts();
o.print_current = true;
let out = output_with(vec![matched("a", 0)], Event::Action(Action::Accept(None)));
// No current item → a bare ending, then the selected item.
assert_eq!(render(&out, &o), "\na\n");
}
#[test]
fn print_current_writes_current_item() {
let mut o = opts();
o.print_current = true;
let mut out = output_with(vec![matched("a", 0)], Event::Action(Action::Accept(None)));
out.current = Some(matched("cur", 0));
assert_eq!(render(&out, &o), "cur\na\n");
}
#[test]
fn accept_key_is_written_before_items() {
let out = output_with(
vec![matched("a", 0)],
Event::Action(Action::Accept(Some("ctrl-x".to_string()))),
);
assert_eq!(render(&out, &opts()), "ctrl-x\na\n");
}
#[test]
fn print_score_follows_each_item() {
let mut o = opts();
o.print_score = true;
let out = output_with(
vec![matched("a", 50), matched("b", 18)],
Event::Action(Action::Accept(None)),
);
assert_eq!(render(&out, &o), "a\n50\nb\n18\n");
}
#[test]
fn strip_ansi_removes_escape_sequences_from_items() {
let mut o = opts();
o.strip_ansi = true;
let out = output_with(
vec![matched("\x1b[31mred\x1b[0m", 0)],
Event::Action(Action::Accept(None)),
);
assert_eq!(render(&out, &o), "red\n");
}
#[test]
fn strip_ansi_keeps_nul_bytes_in_item_output() {
// NUL is not an ANSI escape, so it survives ANSI stripping (matches the
// `--ansi` "a\0b" passthrough behavior).
let mut o = opts();
o.strip_ansi = true;
let out = output_with(vec![matched("a\0b", 0)], Event::Action(Action::Accept(None)));
assert_eq!(render(&out, &o), "a\0b\n");
}
#[test]
fn no_strip_ansi_keeps_escape_sequences_in_output() {
let o = opts(); // strip_ansi defaults to false
assert!(!o.strip_ansi);
let out = output_with(
vec![matched("\x1b[31mred\x1b[0m", 0)],
Event::Action(Action::Accept(None)),
);
assert_eq!(render(&out, &o), "\x1b[31mred\x1b[0m\n");
}
#[test]
fn output_format_overrides_default_serialization() {
let mut o = opts();
// `{}` (the default replstr) expands to the current item via printf, and the
// default per-item serialization is bypassed entirely.
o.output_format = Some("[{}]".to_string());
let mut out = output_with(
vec![matched("a", 0), matched("b", 0)],
Event::Action(Action::Accept(None)),
);
out.current = Some(matched("cur", 0));
assert_eq!(render(&out, &o), "[cur]\n");
}
#[test]
fn bin_options_reflect_flags() {
let mut opts = SkimOptions::default();
opts.print_query = true;
opts.print0 = true;
opts.ansi = true;
opts.no_strip_ansi = false;
let bin = BinOptions::from_opts(&opts);
assert!(bin.print_query);
assert_eq!(bin.output_ending, "\0");
assert!(bin.strip_ansi);
}
#[test]
fn bin_options_strip_ansi_requires_ansi_and_not_no_strip() {
let mut opts = SkimOptions::default();
opts.ansi = true;
opts.no_strip_ansi = true;
assert!(!BinOptions::from_opts(&opts).strip_ansi);
opts.no_strip_ansi = false;
assert!(BinOptions::from_opts(&opts).strip_ansi);
opts.ansi = false;
assert!(!BinOptions::from_opts(&opts).strip_ansi);
}
}

View file

@ -193,7 +193,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
}
let _ = write!(stripped_shell_cmd, " >{}", tmp_stdout.display());
debug!("build cmd {}", &stripped_shell_cmd);
debug!("build cmd {stripped_shell_cmd}");
// Run downstream sk in tmux
let mut popup: Box<dyn SkimPopup> = if zellij::is_available() {
@ -320,109 +320,5 @@ fn sanitize_value(value: String) -> String {
}
#[cfg(test)]
mod tests {
use super::*;
// ── PopupWindowDir::from ──────────────────────────────────────────────────
#[test]
fn popup_window_dir_known_values() {
assert_eq!(PopupWindowDir::from("center"), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("top"), PopupWindowDir::Top);
assert_eq!(PopupWindowDir::from("bottom"), PopupWindowDir::Bottom);
assert_eq!(PopupWindowDir::from("left"), PopupWindowDir::Left);
assert_eq!(PopupWindowDir::from("right"), PopupWindowDir::Right);
}
#[test]
fn popup_window_dir_unknown_falls_back_to_center() {
assert_eq!(PopupWindowDir::from(""), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("foobar"), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("CENTER"), PopupWindowDir::Center); // case-sensitive
}
// ── sanitize_value ────────────────────────────────────────────────────────
#[test]
fn sanitize_value_no_semicolon() {
assert_eq!(sanitize_value("hello".to_string()), "hello");
assert_eq!(sanitize_value("foo=bar".to_string()), "foo=bar");
assert_eq!(sanitize_value(String::new()), "");
}
#[test]
fn sanitize_value_trailing_semicolon_is_escaped() {
assert_eq!(sanitize_value("hello;".to_string()), "hello\\;");
assert_eq!(sanitize_value(";".to_string()), "\\;");
}
#[test]
fn sanitize_value_semicolon_in_middle_unchanged() {
assert_eq!(sanitize_value("hel;lo".to_string()), "hel;lo");
assert_eq!(sanitize_value("a;b;c".to_string()), "a;b;c");
}
// ── push_quoted_arg ───────────────────────────────────────────────────────
// These tests mutate the SHELL env var. `#[serial]` ensures they never run
// concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81
// (edition 2024); the SAFETY invariant holds because `#[serial]` serialises
// access so no other thread reads the var while it is being written.
#[test]
#[serial_test::serial]
fn push_quoted_arg_simple_word_sh() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/sh") };
let mut s = String::new();
push_quoted_arg(&mut s, "hello");
assert_eq!(s, " hello");
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_spaces_are_quoted() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/sh") };
let mut s = String::new();
push_quoted_arg(&mut s, "hello world");
// The result must preserve both words and not be a bare unquoted string
assert!(s.contains("hello"));
assert!(s.contains("world"));
assert_ne!(s.trim(), "hello world"); // must be quoted somehow
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_appends_with_space_prefix() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/sh") };
let mut s = String::from("sk");
push_quoted_arg(&mut s, "--flag");
assert!(s.starts_with("sk "));
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_bash_shell() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/usr/bin/bash") };
let mut s = String::new();
push_quoted_arg(&mut s, "simple");
assert_eq!(s, " simple");
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_zsh_shell() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/zsh") };
let mut s = String::new();
push_quoted_arg(&mut s, "simple");
assert_eq!(s, " simple");
unsafe { std::env::remove_var("SHELL") };
}
}
#[path = "mod_tests.rs"]
mod tests;

100
src/popup/mod_tests.rs Normal file
View file

@ -0,0 +1,100 @@
use super::*;
// ── PopupWindowDir::from ──────────────────────────────────────────────────
#[test]
fn popup_window_dir_known_values() {
assert_eq!(PopupWindowDir::from("center"), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("top"), PopupWindowDir::Top);
assert_eq!(PopupWindowDir::from("bottom"), PopupWindowDir::Bottom);
assert_eq!(PopupWindowDir::from("left"), PopupWindowDir::Left);
assert_eq!(PopupWindowDir::from("right"), PopupWindowDir::Right);
}
#[test]
fn popup_window_dir_unknown_falls_back_to_center() {
assert_eq!(PopupWindowDir::from(""), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("foobar"), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("CENTER"), PopupWindowDir::Center); // case-sensitive
}
// ── sanitize_value ────────────────────────────────────────────────────────
#[test]
fn sanitize_value_no_semicolon() {
assert_eq!(sanitize_value("hello".to_string()), "hello");
assert_eq!(sanitize_value("foo=bar".to_string()), "foo=bar");
assert_eq!(sanitize_value(String::new()), "");
}
#[test]
fn sanitize_value_trailing_semicolon_is_escaped() {
assert_eq!(sanitize_value("hello;".to_string()), "hello\\;");
assert_eq!(sanitize_value(";".to_string()), "\\;");
}
#[test]
fn sanitize_value_semicolon_in_middle_unchanged() {
assert_eq!(sanitize_value("hel;lo".to_string()), "hel;lo");
assert_eq!(sanitize_value("a;b;c".to_string()), "a;b;c");
}
// ── push_quoted_arg ───────────────────────────────────────────────────────
// `push_quoted_arg` always quotes with `shell_quote::Sh`; it does not read the
// SHELL env var, so these tests need no env setup or serialisation.
#[test]
fn push_quoted_arg_simple_word() {
let mut s = String::new();
push_quoted_arg(&mut s, "hello");
assert_eq!(s, " hello");
}
#[test]
fn push_quoted_arg_spaces_are_quoted() {
let mut s = String::new();
push_quoted_arg(&mut s, "hello world");
// The result must preserve both words and not be a bare unquoted string
assert!(s.contains("hello"));
assert!(s.contains("world"));
assert_ne!(s.trim(), "hello world"); // must be quoted somehow
}
#[test]
fn push_quoted_arg_appends_with_space_prefix() {
let mut s = String::from("sk");
push_quoted_arg(&mut s, "--flag");
assert!(s.starts_with("sk "));
}
// ── SkimPopupOutput ───────────────────────────────────────────────────────
#[test]
fn popup_output_text_returns_line() {
let out = SkimPopupOutput {
line: "hello world".to_string(),
};
assert_eq!(out.text(), "hello world");
}
// ── check_env ─────────────────────────────────────────────────────────────
#[test]
#[serial_test::serial]
fn check_env_false_when_already_in_popup() {
// SAFETY: serialised by #[serial]; no concurrent reads of _SKIM_POPUP.
unsafe { std::env::set_var("_SKIM_POPUP", "1") };
// Already inside a popup → never re-enter regardless of multiplexer.
assert!(!check_env());
unsafe { std::env::remove_var("_SKIM_POPUP") };
}
#[test]
#[serial_test::serial]
fn check_env_reflects_multiplexer_availability() {
// SAFETY: serialised by #[serial]; no concurrent reads of _SKIM_POPUP.
unsafe { std::env::remove_var("_SKIM_POPUP") };
// Outside a popup, the result mirrors whether a multiplexer is available.
let expected = tmux::is_available() || zellij::is_available();
assert_eq!(check_env(), expected);
}

View file

@ -14,9 +14,10 @@ pub(super) struct TmuxPopup {
impl TmuxPopup {
fn build(options: &SkimOptions) -> Self {
let arg = options.popup.as_ref().expect("this arg should be present to get here");
let mut cmd = Command::new(
which::which("tmux").expect("tmux not found in path. This should have been caught by is_available"),
);
// `is_available` already guarantees tmux is on PATH before we reach here
// in production; fall back to the bare name so arg-building (and tests)
// work even when the binary cannot be resolved.
let mut cmd = Command::new(which::which("tmux").unwrap_or_else(|_| "tmux".into()));
cmd.arg("display-popup").arg("-E").args([
"-d",
&std::env::current_dir()
@ -91,149 +92,5 @@ impl SkimPopup for TmuxPopup {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
/// Skip the test if `tmux` is not in PATH (CI environments without tmux).
macro_rules! require_tmux {
() => {
if which::which("tmux").is_err() {
return;
}
};
}
fn opts(tmux: &str) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(tmux)
.build()
.expect("valid options")
}
fn opts_with_border(tmux: &str, border: crate::tui::BorderType) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(tmux)
.border(border)
.build()
.expect("valid options")
}
#[test]
fn border_none_does_not_panic() {
require_tmux!();
// Ensure each BorderType variant can be passed without panicking.
for border in [
crate::tui::BorderType::Plain,
crate::tui::BorderType::Rounded,
crate::tui::BorderType::Thick,
crate::tui::BorderType::Double,
] {
let _ = TmuxPopup::build(&opts_with_border("center", border));
}
// No border option
let _ = TmuxPopup::build(&opts("center"));
}
fn args(popup: &TmuxPopup) -> Vec<String> {
popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str())
}
#[test]
fn center_default_size() {
require_tmux!();
let popup = TmuxPopup::build(&opts("center"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("50%"));
assert_eq!(get_flag(&a, "-w"), Some("50%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn center_no_direction_defaults_to_center() {
require_tmux!();
// Bare "50%" with no direction keyword defaults to Center
let popup = TmuxPopup::build(&opts("50%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn top_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("top,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("40%"));
assert_eq!(get_flag(&a, "-w"), Some("100%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("0%"));
}
#[test]
fn bottom_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("bottom,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("30%"));
assert_eq!(get_flag(&a, "-w"), Some("100%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("100%"));
}
#[test]
fn left_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("left,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("100%"));
assert_eq!(get_flag(&a, "-w"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("0%"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn right_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("right,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("100%"));
assert_eq!(get_flag(&a, "-w"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("100%"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn two_dimensional_size_center() {
// "center,WIDTH,HEIGHT" — for Center/Left/Right: height=rhs, width=lhs
require_tmux!();
let popup = TmuxPopup::build(&opts("center,60%,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-w"), Some("60%"));
assert_eq!(get_flag(&a, "-h"), Some("40%"));
}
#[test]
fn two_dimensional_size_top() {
// "top,HEIGHT,WIDTH" — for Top/Bottom: height=lhs, width=rhs
require_tmux!();
let popup = TmuxPopup::build(&opts("top,30%,80%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("30%"));
assert_eq!(get_flag(&a, "-w"), Some("80%"));
}
#[test]
fn add_env_appends_e_flag() {
require_tmux!();
let mut popup = TmuxPopup::build(&opts("center"));
popup.add_env("FOO", "bar");
let a = args(&popup);
assert!(a.windows(2).any(|w| w[0] == "-e" && w[1] == "FOO=bar"));
}
}
#[path = "tmux_tests.rs"]
mod tests;

125
src/popup/tmux_tests.rs Normal file
View file

@ -0,0 +1,125 @@
use super::*;
use crate::options::SkimOptionsBuilder;
fn opts(tmux: &str) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(tmux)
.build()
.expect("valid options")
}
fn opts_with_border(tmux: &str, border: crate::tui::BorderType) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(tmux)
.border(border)
.build()
.expect("valid options")
}
#[test]
fn border_none_does_not_panic() {
// Ensure each BorderType variant can be passed without panicking.
for border in [
crate::tui::BorderType::Plain,
crate::tui::BorderType::Rounded,
crate::tui::BorderType::Thick,
crate::tui::BorderType::Double,
] {
let _ = TmuxPopup::build(&opts_with_border("center", border));
}
// No border option
let _ = TmuxPopup::build(&opts("center"));
}
fn args(popup: &TmuxPopup) -> Vec<String> {
popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str())
}
#[test]
fn center_default_size() {
let popup = TmuxPopup::build(&opts("center"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("50%"));
assert_eq!(get_flag(&a, "-w"), Some("50%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn center_no_direction_defaults_to_center() {
// Bare "50%" with no direction keyword defaults to Center
let popup = TmuxPopup::build(&opts("50%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn top_direction() {
let popup = TmuxPopup::build(&opts("top,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("40%"));
assert_eq!(get_flag(&a, "-w"), Some("100%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("0%"));
}
#[test]
fn bottom_direction() {
let popup = TmuxPopup::build(&opts("bottom,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("30%"));
assert_eq!(get_flag(&a, "-w"), Some("100%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("100%"));
}
#[test]
fn left_direction() {
let popup = TmuxPopup::build(&opts("left,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("100%"));
assert_eq!(get_flag(&a, "-w"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("0%"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn right_direction() {
let popup = TmuxPopup::build(&opts("right,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("100%"));
assert_eq!(get_flag(&a, "-w"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("100%"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn two_dimensional_size_center() {
// "center,WIDTH,HEIGHT" — for Center/Left/Right: height=rhs, width=lhs
let popup = TmuxPopup::build(&opts("center,60%,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-w"), Some("60%"));
assert_eq!(get_flag(&a, "-h"), Some("40%"));
}
#[test]
fn two_dimensional_size_top() {
// "top,HEIGHT,WIDTH" — for Top/Bottom: height=lhs, width=rhs
let popup = TmuxPopup::build(&opts("top,30%,80%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("30%"));
assert_eq!(get_flag(&a, "-w"), Some("80%"));
}
#[test]
fn add_env_appends_e_flag() {
let mut popup = TmuxPopup::build(&opts("center"));
popup.add_env("FOO", "bar");
let a = args(&popup);
assert!(a.windows(2).any(|w| w[0] == "-e" && w[1] == "FOO=bar"));
}

View file

@ -41,9 +41,10 @@ fn align_end_coord(size: Size, var: &str) -> Size {
impl ZellijPopup {
fn build(options: &SkimOptions) -> Self {
let mut cmd = Command::new(
which::which("zellij").expect("zellij not found in path. This should have been caught by is_available"),
);
// `is_available` already guarantees zellij is on PATH before we reach
// here in production; fall back to the bare name so arg-building (and
// tests) work even when the binary cannot be resolved.
let mut cmd = Command::new(which::which("zellij").unwrap_or_else(|_| "zellij".into()));
cmd.arg("run")
.arg("--floating")
.arg("--block-until-exit")
@ -116,7 +117,7 @@ impl SkimPopup for ZellijPopup {
let _ = write!(
self.env,
" {key}={}",
&String::from_utf8_lossy(&shell_quote::Sh::quote_vec(value))
String::from_utf8_lossy(&shell_quote::Sh::quote_vec(value))
);
}
@ -136,160 +137,5 @@ impl SkimPopup for ZellijPopup {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
/// Skip the test if `zellij` is not in PATH (CI environments without zellij).
macro_rules! require_zellij {
() => {
if which::which("zellij").is_err() {
return;
}
};
}
fn opts(popup: &str) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(popup)
.build()
.expect("valid options")
}
fn args(popup: &ZellijPopup) -> Vec<String> {
popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str())
}
// ── middle_coord ────────────────────────────────────────────────────────
// Tests that mutate COLUMNS are annotated with #[serial] so they never run
// concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81
// (edition 2024); the SAFETY invariant holds because #[serial] serialises
// access so no other thread reads the var while it is being written.
#[test]
fn middle_coord_percent() {
// 50% wide in a 100% viewport → offset should be 25%
assert_eq!(middle_coord(Size::Percent(50), "COLUMNS"), Size::Percent(25));
}
#[test]
#[serial_test::serial]
fn middle_coord_fixed_uses_env_var() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
// 20 cols wide → offset = (80 - 20) / 2 = 30
assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30));
unsafe { std::env::remove_var("COLUMNS") };
}
#[test]
#[serial_test::serial]
fn middle_coord_fixed_fallback() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::remove_var("COLUMNS") };
// fallback width = 80; (80 - 20) / 2 = 30
assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30));
}
// ── align_end_coord ──────────────────────────────────────────────────────
#[test]
fn align_end_coord_percent() {
// 30% → end offset = 70%
assert_eq!(align_end_coord(Size::Percent(30), "COLUMNS"), Size::Percent(70));
}
#[test]
#[serial_test::serial]
fn align_end_coord_fixed_uses_env_var() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
// 20 cols wide → end offset = 80 - 20 = 60
assert_eq!(align_end_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(60));
unsafe { std::env::remove_var("COLUMNS") };
}
// ── from_options / build ─────────────────────────────────────────────────
#[test]
fn center_default_size() {
require_zellij!();
let popup = ZellijPopup::build(&opts("center"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("50%"));
assert_eq!(get_flag(&a, "--width"), Some("50%"));
}
#[test]
fn top_direction() {
require_zellij!();
let popup = ZellijPopup::build(&opts("top,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("40%"));
assert_eq!(get_flag(&a, "--width"), Some("100%"));
assert_eq!(get_flag(&a, "-y"), Some("0"));
}
#[test]
fn left_direction() {
require_zellij!();
let popup = ZellijPopup::build(&opts("left,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--width"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("0"));
}
#[test]
#[serial_test::serial]
fn right_direction() {
require_zellij!();
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
let popup = ZellijPopup::build(&opts("right,25%"));
let a = args(&popup);
// width = 25%, x = align_end_coord(25%, "COLUMNS") = 75%
assert_eq!(get_flag(&a, "--width"), Some("25%"));
assert_eq!(get_flag(&a, "-x"), Some("75%"));
unsafe { std::env::remove_var("COLUMNS") };
}
#[test]
fn borderless_when_no_border_option() {
require_zellij!();
let popup = ZellijPopup::build(
&SkimOptionsBuilder::default()
.popup("center")
.no_border(true)
.build()
.unwrap(),
);
let a = args(&popup);
assert!(a.contains(&"--borderless".to_string()));
}
#[test]
fn no_borderless_when_border_set() {
require_zellij!();
let opts = SkimOptionsBuilder::default()
.popup("center")
.border(crate::tui::BorderType::Plain)
.build()
.expect("valid options");
let popup = ZellijPopup::build(&opts);
let a = args(&popup);
assert!(!a.contains(&"--borderless".to_string()));
}
#[test]
fn add_env_appends_to_env_string() {
require_zellij!();
let mut popup = ZellijPopup::build(&opts("center"));
popup.add_env("FOO", "bar");
popup.add_env("BAZ", "qux");
assert_eq!(popup.env, " FOO=bar BAZ=qux");
}
}
#[path = "zellij_tests.rs"]
mod tests;

181
src/popup/zellij_tests.rs Normal file
View file

@ -0,0 +1,181 @@
use super::*;
use crate::options::SkimOptionsBuilder;
fn opts(popup: &str) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(popup)
.build()
.expect("valid options")
}
fn args(popup: &ZellijPopup) -> Vec<String> {
popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str())
}
// ── middle_coord ────────────────────────────────────────────────────────
// Tests that mutate COLUMNS are annotated with #[serial] so they never run
// concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81
// (edition 2024); the SAFETY invariant holds because #[serial] serialises
// access so no other thread reads the var while it is being written.
#[test]
fn middle_coord_percent() {
// 50% wide in a 100% viewport → offset should be 25%
assert_eq!(middle_coord(Size::Percent(50), "COLUMNS"), Size::Percent(25));
}
#[test]
#[serial_test::serial]
fn middle_coord_fixed_uses_env_var() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
// 20 cols wide → offset = (80 - 20) / 2 = 30
assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30));
unsafe { std::env::remove_var("COLUMNS") };
}
#[test]
#[serial_test::serial]
fn middle_coord_fixed_fallback() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::remove_var("COLUMNS") };
// fallback width = 80; (80 - 20) / 2 = 30
assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30));
}
// ── align_end_coord ──────────────────────────────────────────────────────
#[test]
fn align_end_coord_percent() {
// 30% → end offset = 70%
assert_eq!(align_end_coord(Size::Percent(30), "COLUMNS"), Size::Percent(70));
}
#[test]
fn middle_coord_neg() {
// Negative sizes are halved into a fixed offset.
assert_eq!(middle_coord(Size::Neg(20), "COLUMNS"), Size::Fixed(10));
}
#[test]
fn align_end_coord_neg() {
// Negative sizes map straight to a fixed offset.
assert_eq!(align_end_coord(Size::Neg(20), "COLUMNS"), Size::Fixed(20));
}
#[test]
#[serial_test::serial]
fn align_end_coord_fixed_uses_env_var() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
// 20 cols wide → end offset = 80 - 20 = 60
assert_eq!(align_end_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(60));
unsafe { std::env::remove_var("COLUMNS") };
}
// ── from_options / build ─────────────────────────────────────────────────
#[test]
fn center_default_size() {
let popup = ZellijPopup::build(&opts("center"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("50%"));
assert_eq!(get_flag(&a, "--width"), Some("50%"));
}
#[test]
fn top_direction() {
let popup = ZellijPopup::build(&opts("top,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("40%"));
assert_eq!(get_flag(&a, "--width"), Some("100%"));
assert_eq!(get_flag(&a, "-y"), Some("0"));
}
#[test]
fn left_direction() {
let popup = ZellijPopup::build(&opts("left,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--width"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("0"));
}
#[test]
#[serial_test::serial]
fn right_direction() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
let popup = ZellijPopup::build(&opts("right,25%"));
let a = args(&popup);
// width = 25%, x = align_end_coord(25%, "COLUMNS") = 75%
assert_eq!(get_flag(&a, "--width"), Some("25%"));
assert_eq!(get_flag(&a, "-x"), Some("75%"));
unsafe { std::env::remove_var("COLUMNS") };
}
#[test]
#[serial_test::serial]
fn bottom_direction() {
// SAFETY: serialised by #[serial]; no concurrent reads of ROWS.
unsafe { std::env::set_var("ROWS", "40") };
let popup = ZellijPopup::build(&opts("bottom,25%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("25%"));
assert_eq!(get_flag(&a, "--width"), Some("100%"));
// y = align_end_coord(25%, "ROWS") = 75%
assert_eq!(get_flag(&a, "-y"), Some("75%"));
unsafe { std::env::remove_var("ROWS") };
}
#[test]
fn explicit_height_and_width() {
// Two comma-separated sizes give explicit height,width per direction.
let popup = ZellijPopup::build(&opts("center,40%,30%"));
let a = args(&popup);
// Center: (height, width) = (rhs, lhs) = (30%, 40%)
assert_eq!(get_flag(&a, "--height"), Some("30%"));
assert_eq!(get_flag(&a, "--width"), Some("40%"));
}
#[test]
fn from_options_builds_popup() {
// Smoke test that the trait constructor wraps `build` without panicking.
let _popup: Box<dyn SkimPopup> = ZellijPopup::from_options(&opts("center"));
}
#[test]
fn borderless_when_no_border_option() {
let popup = ZellijPopup::build(
&SkimOptionsBuilder::default()
.popup("center")
.no_border(true)
.build()
.unwrap(),
);
let a = args(&popup);
assert!(a.contains(&"--borderless".to_string()));
}
#[test]
fn no_borderless_when_border_set() {
let opts = SkimOptionsBuilder::default()
.popup("center")
.border(crate::tui::BorderType::Plain)
.build()
.expect("valid options");
let popup = ZellijPopup::build(&opts);
let a = args(&popup);
assert!(!a.contains(&"--borderless".to_string()));
}
#[test]
fn add_env_appends_to_env_string() {
let mut popup = ZellijPopup::build(&opts("center"));
popup.add_env("FOO", "bar");
popup.add_env("BAZ", "qux");
assert_eq!(popup.env, " FOO=bar BAZ=qux");
}

View file

@ -215,3 +215,115 @@ where
tx_interrupt
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use std::io::Cursor;
use std::time::{Duration, Instant};
fn source(text: &str) -> SkimItemReceiver {
SkimItemReader::default().of_bufread(Cursor::new(text.to_owned().into_bytes()))
}
/// Spin until `cond` holds or a short timeout elapses.
fn wait_until(mut cond: impl FnMut() -> bool) {
let start = Instant::now();
while !cond() && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(2));
}
}
#[test]
fn collect_streams_items_into_pool() {
let pool = Arc::new(ItemPool::new());
let mut reader = Reader::default().source(Some(source("a\nb\nc\n")));
let control = reader.collect(pool.clone(), "");
wait_until(|| pool.len() == 3);
assert_eq!(pool.len(), 3);
drop(control);
}
#[test]
fn run_sends_items_to_channel() {
let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
let mut reader = Reader::default().source(Some(source("x\ny\n")));
let control = reader.run(tx, "");
wait_until(|| control.is_done());
let mut count = 0;
while let Ok(Some(batch)) = rx.try_recv() {
count += batch.len();
}
assert_eq!(count, 2);
drop(control);
}
#[test]
fn is_done_true_after_completion() {
let pool = Arc::new(ItemPool::new());
let mut reader = Reader::default().source(Some(source("only\n")));
let control = reader.collect(pool.clone(), "");
wait_until(|| control.is_done());
assert!(control.is_done());
}
#[test]
fn kill_stops_all_components() {
let pool = Arc::new(ItemPool::new());
let mut reader = Reader::default().source(Some(source("a\nb\n")));
let mut control = reader.collect(pool, "");
control.kill();
// After kill, no components remain running.
assert!(control.is_done());
}
#[test]
fn run_without_source_invokes_command() {
// With no preset source, `run` falls back to invoking the command via
// the command collector.
#[cfg(unix)]
let cmd = "printf 'a\\nb\\n'";
#[cfg(windows)]
let cmd = "echo a & echo b";
let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
let mut reader = Reader::default();
let control = reader.run(tx, cmd);
wait_until(|| control.is_done());
let mut count = 0;
while let Ok(Some(batch)) = rx.try_recv() {
count += batch.len();
}
assert_eq!(count, 2);
drop(control);
}
#[test]
fn collect_without_source_invokes_command() {
// Same command-invoking fallback for the pool-collecting path.
#[cfg(unix)]
let cmd = "printf 'x\\ny\\nz\\n'";
#[cfg(windows)]
let cmd = "echo x & echo y & echo z";
let pool = Arc::new(ItemPool::new());
let mut reader = Reader::default();
let control = reader.collect(pool.clone(), cmd);
wait_until(|| pool.len() == 3);
assert_eq!(pool.len(), 3);
drop(control);
}
#[test]
fn take_returns_empty_for_pool_collection() {
// `collect` routes items to the pool, not the control's own buffer.
let pool = Arc::new(ItemPool::new());
let mut reader = Reader::default().source(Some(source("a\n")));
let control = reader.collect(pool, "");
wait_until(|| control.is_done());
assert!(control.take().is_empty());
}
}

View file

@ -61,114 +61,5 @@ pub fn generate_key_bindings(sh: &Shell, output: &mut impl Write) -> std::io::Re
}
#[cfg(test)]
mod tests {
use super::*;
fn completions_for(sh: &Shell) -> String {
let mut buf = Vec::new();
generate_completions(sh, &mut buf);
String::from_utf8(buf).expect("completion output is valid UTF-8")
}
#[test]
fn completions_bash_contains_sk() {
let out = completions_for(&Shell::Bash);
assert!(out.contains("sk"), "bash completion should reference 'sk'");
assert!(
out.contains("--query") || out.contains("query"),
"bash completion should include --query"
);
}
#[test]
fn completions_zsh_contains_sk() {
let out = completions_for(&Shell::Zsh);
assert!(out.contains("sk"), "zsh completion should reference 'sk'");
assert!(
out.contains("--multi") || out.contains("multi"),
"zsh completion should include --multi"
);
}
#[test]
fn completions_fish_contains_sk() {
let out = completions_for(&Shell::Fish);
assert!(out.contains("sk"), "fish completion should reference 'sk'");
}
#[test]
fn completions_nushell_is_non_empty() {
let out = completions_for(&Shell::Nushell);
assert!(!out.is_empty(), "nushell completion should not be empty");
}
#[test]
fn completions_elvish_is_non_empty() {
let out = completions_for(&Shell::Elvish);
assert!(!out.is_empty(), "elvish completion should not be empty");
}
#[test]
fn completions_powershell_is_non_empty() {
let out = completions_for(&Shell::PowerShell);
assert!(!out.is_empty(), "powershell completion should not be empty");
}
fn key_bindings_for(sh: &Shell) -> String {
let mut buf = Vec::new();
generate_key_bindings(sh, &mut buf).expect("key-bindings generation failed");
String::from_utf8(buf).expect("key-bindings output is valid UTF-8")
}
#[test]
fn key_bindings_bash() {
let out = key_bindings_for(&Shell::Bash);
assert!(
out.starts_with("# skim key bindings for bash"),
"unexpected bash header"
);
assert!(out.contains("__skim_select__()"), "missing __skim_select__ function");
}
#[test]
fn key_bindings_zsh() {
let out = key_bindings_for(&Shell::Zsh);
assert!(out.starts_with("# skim key bindings for zsh"), "unexpected zsh header");
for func in [
"__skimcmd()",
"__skim_comprun()",
"__skim_extract_command()",
"__skim_generic_path_completion()",
"_skim_complete()",
"_skim_complete_kill()",
] {
assert!(out.contains(func), "missing zsh function {func}");
}
}
#[test]
fn key_bindings_fish() {
let out = key_bindings_for(&Shell::Fish);
assert!(
out.starts_with("#!/bin/fish"),
"fish key-bindings should start with shebang"
);
for func in [
"function __skimcmd",
"function __skim_parse_commandline",
"function __skim_get_dir",
] {
assert!(out.contains(func), "missing fish function '{func}'");
}
}
#[test]
fn key_bindings_unsupported_shells_are_empty() {
for sh in [Shell::Elvish, Shell::Nushell, Shell::PowerShell] {
assert!(
key_bindings_for(&sh).is_empty(),
"{sh:?} should produce no key-bindings output"
);
}
}
}
#[path = "shell_tests.rs"]
mod tests;

109
src/shell_tests.rs Normal file
View file

@ -0,0 +1,109 @@
use super::*;
fn completions_for(sh: &Shell) -> String {
let mut buf = Vec::new();
generate_completions(sh, &mut buf);
String::from_utf8(buf).expect("completion output is valid UTF-8")
}
#[test]
fn completions_bash_contains_sk() {
let out = completions_for(&Shell::Bash);
assert!(out.contains("sk"), "bash completion should reference 'sk'");
assert!(
out.contains("--query") || out.contains("query"),
"bash completion should include --query"
);
}
#[test]
fn completions_zsh_contains_sk() {
let out = completions_for(&Shell::Zsh);
assert!(out.contains("sk"), "zsh completion should reference 'sk'");
assert!(
out.contains("--multi") || out.contains("multi"),
"zsh completion should include --multi"
);
}
#[test]
fn completions_fish_contains_sk() {
let out = completions_for(&Shell::Fish);
assert!(out.contains("sk"), "fish completion should reference 'sk'");
}
#[test]
fn completions_nushell_is_non_empty() {
let out = completions_for(&Shell::Nushell);
assert!(!out.is_empty(), "nushell completion should not be empty");
}
#[test]
fn completions_elvish_is_non_empty() {
let out = completions_for(&Shell::Elvish);
assert!(!out.is_empty(), "elvish completion should not be empty");
}
#[test]
fn completions_powershell_is_non_empty() {
let out = completions_for(&Shell::PowerShell);
assert!(!out.is_empty(), "powershell completion should not be empty");
}
fn key_bindings_for(sh: &Shell) -> String {
let mut buf = Vec::new();
generate_key_bindings(sh, &mut buf).expect("key-bindings generation failed");
String::from_utf8(buf).expect("key-bindings output is valid UTF-8")
}
#[test]
fn key_bindings_bash() {
let out = key_bindings_for(&Shell::Bash);
assert!(
out.starts_with("# skim key bindings for bash"),
"unexpected bash header"
);
assert!(out.contains("__skim_select__()"), "missing __skim_select__ function");
}
#[test]
fn key_bindings_zsh() {
let out = key_bindings_for(&Shell::Zsh);
assert!(out.starts_with("# skim key bindings for zsh"), "unexpected zsh header");
for func in [
"__skimcmd()",
"__skim_comprun()",
"__skim_extract_command()",
"__skim_generic_path_completion()",
"_skim_complete()",
"_skim_complete_kill()",
] {
assert!(out.contains(func), "missing zsh function {func}");
}
}
#[test]
fn key_bindings_fish() {
let out = key_bindings_for(&Shell::Fish);
assert!(
out.starts_with("#!/bin/fish"),
"fish key-bindings should start with shebang"
);
for func in [
"function __skimcmd",
"function __skim_parse_commandline",
"function __skim_get_dir",
] {
assert!(out.contains(func), "missing fish function '{func}'");
}
}
#[test]
fn key_bindings_unsupported_shells_are_empty() {
for sh in [Shell::Elvish, Shell::Nushell, Shell::PowerShell] {
assert!(
key_bindings_for(&sh).is_empty(),
"{sh:?} should produce no key-bindings output"
);
}
}

View file

@ -341,7 +341,7 @@ where
///
/// This will call `init_listener`, which requires a tokio runtime
/// Though it is not technically async code, this is a good hint.
#[allow(clippy::unused_async)]
#[allow(clippy::unused_async, unknown_lints, clippy::unused_async_trait_impl)]
pub async fn enter(&mut self) -> Result<()> {
debug!("Entering TUI");
let tui = self
@ -711,3 +711,7 @@ where
.await
}
}
#[cfg(test)]
#[path = "skim_tests.rs"]
mod tests;

View file

@ -100,3 +100,28 @@ impl Debug for dyn SkimItem {
f.write_fmt(format_args!("SkimItem {{ text: {} }}", self.text()))
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn blanket_impl_default_methods() {
let item = "hello".to_string();
assert_eq!(item.text(), "hello");
// `output` defaults to `text`.
assert_eq!(item.output(), "hello");
assert!(item.get_matching_ranges().is_none());
assert!(!item.disabled());
}
#[test]
fn display_and_debug_for_trait_object() {
let item: Arc<dyn SkimItem> = Arc::new("world".to_string());
let as_dyn: &dyn SkimItem = &*item;
assert_eq!(format!("{as_dyn}"), "world");
assert!(format!("{as_dyn:?}").contains("world"));
}
}

215
src/skim_tests.rs Normal file
View file

@ -0,0 +1,215 @@
use super::*;
use ratatui::backend::TestBackend;
use std::time::{Duration, Instant};
/// Spin until `cond` holds or a short timeout elapses.
fn wait_until(mut cond: impl FnMut() -> bool) {
let start = Instant::now();
while !cond() && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(2));
}
}
/// Build a `Skim<TestBackend>` pre-loaded with `items`, started, with its
/// reader drained — mirroring the snapshot test harness setup.
fn started_skim_with(options: SkimOptions, items: &[&str]) -> Skim<TestBackend> {
let (tx, rx) = crate::prelude::unbounded();
let batch: Vec<Arc<dyn SkimItem>> = items
.iter()
.map(|s| Arc::new(s.to_string()) as Arc<dyn SkimItem>)
.collect();
tx.send(batch).unwrap();
drop(tx); // close the channel so the reader finishes
let backend = TestBackend::new(40, 10);
let tui = Tui::new_with_height_and_backend(backend, Size::Percent(100)).unwrap();
let mut skim = Skim::<TestBackend>::init(options, Some(rx)).unwrap();
skim.init_tui_with(tui);
skim.start();
skim
}
fn started_skim(items: &[&str]) -> Skim<TestBackend> {
started_skim_with(SkimOptions::default().build(), items)
}
#[test]
fn accessors_expose_app_and_tui() {
let mut skim = started_skim(&["alpha", "beta"]);
// Immutable and mutable app/tui accessors.
assert!(!skim.app().should_quit);
skim.app_mut().input.value = "x".to_string();
assert_eq!(skim.app().input.value, "x");
// The TUI accessors and combined borrow do not panic once initialized.
let _ = skim.tui_ref();
let _ = skim.tui_mut();
let (_app, _tui) = skim.app_and_tui();
// The event sender is cloneable while the TUI is live.
let _sender = skim.event_sender();
// Final event defaults to Quit and skim has not been asked to quit yet.
assert!(matches!(skim.final_event(), Event::Quit));
assert!(!skim.should_quit());
}
#[test]
fn should_enter_is_false_in_filter_mode() {
let mut options = SkimOptions::default();
options.filter = Some(String::new());
let options = options.build();
let mut skim = started_skim_with(options, &["a", "b", "c"]);
// Filter mode processes everything synchronously and never enters the TUI.
assert!(!skim.should_enter());
assert_eq!(skim.app().item_list.items.len(), 3);
}
#[test]
fn should_enter_is_false_for_select_1_single_match() {
let mut options = SkimOptions::default();
options.select_1 = true;
let options = options.build();
// A single matching item satisfies select-1, so skim exits early.
let mut skim = started_skim_with(options, &["only"]);
assert!(!skim.should_enter());
}
#[test]
fn should_enter_is_true_in_sync_mode_with_matches() {
let mut options = SkimOptions::default();
options.sync = true;
let options = options.build();
// Sync mode waits for all items, then enters the TUI to display them.
let mut skim = started_skim_with(options, &["a", "b"]);
assert!(skim.should_enter());
}
#[test]
fn should_enter_is_true_for_exit_0_with_matches() {
let mut options = SkimOptions::default();
options.exit_0 = true;
let options = options.build();
// exit-0 only bails when nothing matches; here items match so we enter.
let mut skim = started_skim_with(options, &["a", "b"]);
assert!(skim.should_enter());
}
#[test]
fn output_collects_results_and_marks_abort() {
let mut skim = started_skim(&["a", "b"]);
wait_until(|| skim.check_reader());
wait_until(|| skim.matcher_stopped());
let output = skim.output();
// The default final_event (Quit) is treated as an abort.
assert!(output.is_abort);
// Non-interactive, no cmd_query → the command is the initial command.
assert_eq!(output.cmd, "");
}
#[test]
fn output_uses_input_as_cmd_in_interactive_mode() {
let mut options = SkimOptions::default();
options.interactive = true;
let options = options.build();
let mut skim = started_skim_with(options, &["a"]);
skim.app_mut().input.value = "typed".to_string();
wait_until(|| skim.check_reader());
let output = skim.output();
assert_eq!(output.cmd, "typed");
assert_eq!(output.query, "typed");
}
#[test]
fn output_uses_cmd_query_when_set() {
let mut options = SkimOptions::default();
options.cmd_query = Some("preset".to_string());
let options = options.build();
let mut skim = started_skim_with(options, &["a"]);
wait_until(|| skim.check_reader());
let output = skim.output();
assert_eq!(output.cmd, "preset");
}
#[test]
fn multi_selection_flows_into_output_and_serializes() {
// multi-select -> SkimOutput -> CLI serialization. The matcher only fills the
// visible list on render, so we populate it directly and exercise selection +
// output + serialization. (The reader -> matcher half is covered by
// `reader_and_matcher_complete`.)
use crate::Rank;
use crate::item::{MatchedItem, RankBuilder};
let mut options = SkimOptions::default();
options.multi = true;
let options = options.build();
let mut skim = started_skim_with(options, &["a", "b", "c"]);
wait_until(|| skim.check_reader());
wait_until(|| skim.matcher_stopped());
let rb = RankBuilder::default();
let mk = |t: &str| MatchedItem::new(Arc::new(t.to_string()) as Arc<dyn SkimItem>, Rank::default(), None, &rb);
skim.app_mut().item_list.append(&mut vec![mk("a"), mk("b"), mk("c")]);
// Toggle "a" and "c" into the selection, as BTab would.
skim.app_mut().item_list.toggle_at(0);
skim.app_mut().item_list.toggle_at(2);
let output = skim.output();
let texts: Vec<String> = output.selected_items.iter().map(|i| i.output().into_owned()).collect();
assert_eq!(texts, vec!["a", "c"]);
// --print0 serialization of the multi-selection.
let mut print0 = SkimOptions::default();
print0.print0 = true;
let bin = crate::BinOptions::from_opts(&print0);
let mut buf = Vec::new();
output.write_output(&mut buf, &bin).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "a\0c\0");
}
#[test]
fn reader_and_matcher_complete() {
let mut skim = started_skim(&["one", "two", "three"]);
// The reader drains and reports done via check_reader.
wait_until(|| skim.check_reader());
assert!(skim.reader_done());
// After the reader finishes, the matcher eventually stops.
wait_until(|| skim.matcher_stopped());
assert!(skim.matcher_stopped());
}
#[test]
fn try_flush_render_emits_render_when_due() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
let mut skim = started_skim(&["a"]);
// Mark a render as needed and age the frame-rate gate so it is due.
skim.app.needs_render.store(true, Ordering::Relaxed);
let now = Instant::now();
skim.app.last_render_timer = now.checked_sub(Duration::from_secs(1)).unwrap_or(now);
skim.try_flush_render();
// The render flag is cleared and a Render event is queued on the TUI.
assert!(!skim.app.needs_render.load(Ordering::Relaxed));
let mut saw_render = false;
while let Ok(ev) = skim.tui.as_mut().unwrap().event_rx.try_recv() {
if matches!(ev, Event::Render) {
saw_render = true;
}
}
assert!(saw_render);
}
#[test]
fn try_flush_render_noop_when_not_needed() {
use std::sync::atomic::Ordering;
let mut skim = started_skim(&["a"]);
// No render requested → nothing happens, no panic.
skim.app.needs_render.store(false, Ordering::Relaxed);
skim.try_flush_render();
assert!(!skim.app.needs_render.load(Ordering::Relaxed));
}

View file

@ -80,6 +80,7 @@ impl<T: ?Sized> Drop for SpinLockGuard<'_, T> {
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use std::sync::Arc;

View file

@ -390,301 +390,5 @@ fn set_style(s: &mut Style, layer: &str, color: Option<Color>, modifier: Modifie
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base_themes() {
// Test that base themes have expected properties
let none = ColorTheme::none();
// Spinner should be bold even in none theme
assert!(none.spinner.add_modifier.contains(Modifier::BOLD));
let bw = ColorTheme::bw();
assert!(bw.matched.add_modifier.contains(Modifier::UNDERLINED));
assert!(bw.current.add_modifier.contains(Modifier::REVERSED));
let theme_16 = ColorTheme::default16();
assert_eq!(theme_16.matched.fg, Some(Color::Green));
assert_eq!(theme_16.matched.bg, None);
let dark = ColorTheme::dark256();
assert_eq!(dark.matched.fg, Some(Color::Indexed(108)));
assert_eq!(dark.matched.bg, Some(Color::Indexed(0)));
let molokai = ColorTheme::molokai256();
assert_eq!(molokai.matched.fg, Some(Color::Indexed(234)));
assert_eq!(molokai.matched.bg, Some(Color::Indexed(186)));
let light = ColorTheme::light256();
assert_eq!(light.matched.fg, Some(Color::Indexed(0)));
assert_eq!(light.matched.bg, Some(Color::Indexed(220)));
}
#[test]
fn test_from_options_base_themes() {
// Test base theme names
let dark = ColorTheme::from_options("dark");
assert!(dark.matched.fg.is_some());
let molokai = ColorTheme::from_options("molokai");
assert!(molokai.matched.fg.is_some());
let light = ColorTheme::from_options("light");
assert!(light.matched.fg.is_some());
let theme_16 = ColorTheme::from_options("16");
assert!(theme_16.matched.fg.is_some());
let bw = ColorTheme::from_options("bw");
assert!(bw.matched.add_modifier.contains(Modifier::UNDERLINED));
// Test that "none" theme uses reset style (which may have default colors from terminal)
let none = ColorTheme::from_options("none");
// Spinner should still be bold even in none theme
assert!(none.spinner.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_ansi_color_parsing() {
// Test ANSI color (0-255)
let theme = ColorTheme::from_options("matched:108");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("prompt:25");
assert_eq!(theme.prompt.fg, Some(Color::Indexed(25)));
}
#[test]
fn test_rgb_hex_color_parsing() {
// Test RGB hex color (#rrggbb)
let theme = ColorTheme::from_options("matched:#ff0000");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 0, 0)));
let theme = ColorTheme::from_options("prompt:#00ff00");
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 255, 0)));
let theme = ColorTheme::from_options("info:#0000ff");
assert_eq!(theme.info.fg, Some(Color::Rgb(0, 0, 255)));
}
#[test]
fn test_color_with_modifiers() {
// Test color with bold modifier
let theme = ColorTheme::from_options("matched:108:bold");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
// Test color with underline modifier
let theme = ColorTheme::from_options("matched:108:underlined");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
// Test color with multiple modifiers (using +)
let theme = ColorTheme::from_options("matched:108:bold:underlined");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_modifier_shortcuts() {
// Test short modifier names
let theme = ColorTheme::from_options("matched:108:b");
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("matched:108:u");
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("matched:108:i");
assert!(theme.matched.add_modifier.contains(Modifier::ITALIC));
let theme = ColorTheme::from_options("matched:108:r");
assert!(theme.matched.add_modifier.contains(Modifier::REVERSED));
let theme = ColorTheme::from_options("matched:108:d");
assert!(theme.matched.add_modifier.contains(Modifier::DIM));
let theme = ColorTheme::from_options("matched:108:c");
assert!(theme.matched.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn test_regular_modifier_reset() {
// Test that 'regular' or 'x' resets modifiers
let theme = ColorTheme::from_options("matched:108:x:bold");
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert!(!theme.matched.add_modifier.contains(Modifier::ITALIC));
let theme = ColorTheme::from_options("matched:108:regular:underlined");
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_multiple_color_components() {
// Test multiple color components separated by comma
let theme = ColorTheme::from_options("matched:108,prompt:25");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert_eq!(theme.prompt.fg, Some(Color::Indexed(25)));
let theme = ColorTheme::from_options("matched:#ff0000:bold,prompt:#00ff00:underlined");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 0, 0)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 255, 0)));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_component_name_aliases() {
// Test that aliases work correctly
let theme = ColorTheme::from_options("hl:108");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("fg+:254");
assert_eq!(theme.current.fg, Some(Color::Indexed(254)));
let theme = ColorTheme::from_options("bg+:236");
assert_eq!(theme.current.bg, Some(Color::Indexed(236)));
let theme = ColorTheme::from_options("hl+:151");
// hl+ is an alias for current_match
assert_eq!(theme.current_match.fg, Some(Color::Indexed(151)));
let theme = ColorTheme::from_options("pointer:161");
assert_eq!(theme.cursor.fg, Some(Color::Indexed(161)));
let theme = ColorTheme::from_options("marker:168");
assert_eq!(theme.selected.fg, Some(Color::Indexed(168)));
}
#[test]
fn test_background_color() {
// Test setting background color explicitly
let theme = ColorTheme::from_options("matched_bg:0");
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
let theme = ColorTheme::from_options("matched-bg:236");
assert_eq!(theme.matched.bg, Some(Color::Indexed(236)));
}
#[test]
fn test_default_theme_with_overrides() {
// Test overriding default theme
let theme = ColorTheme::from_options("default,matched:200");
assert_eq!(theme.matched.fg, Some(Color::Indexed(200)));
// Other colors should still be from default theme
assert!(theme.prompt.fg.is_some());
}
#[test]
fn test_theme_with_overrides() {
// Test overriding theme
for opts in &["16,prompt:200", "prompt:150,16,prompt:200"] {
let theme = ColorTheme::from_options(opts);
assert_eq!(theme.prompt.fg, Some(Color::Indexed(200)));
// Other colors should still be from given theme
assert_eq!(theme.matched.fg, Some(Color::Green));
assert_eq!(theme.matched.bg, None);
}
}
#[test]
fn test_all_component_names() {
// Test all valid component names with their specific colors
let theme = ColorTheme::from_options("normal:108");
assert_eq!(theme.normal.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("matched:109");
assert_eq!(theme.matched.fg, Some(Color::Indexed(109)));
let theme = ColorTheme::from_options("current:110");
assert_eq!(theme.current.fg, Some(Color::Indexed(110)));
let theme = ColorTheme::from_options("current_match:111");
// current_match should now correctly set current_match.fg
assert_eq!(theme.current_match.fg, Some(Color::Indexed(111)));
let theme = ColorTheme::from_options("query:112");
assert_eq!(theme.query.fg, Some(Color::Indexed(112)));
let theme = ColorTheme::from_options("spinner:113");
assert_eq!(theme.spinner.fg, Some(Color::Indexed(113)));
let theme = ColorTheme::from_options("info:114");
assert_eq!(theme.info.fg, Some(Color::Indexed(114)));
let theme = ColorTheme::from_options("prompt:115");
assert_eq!(theme.prompt.fg, Some(Color::Indexed(115)));
let theme = ColorTheme::from_options("cursor:116");
assert_eq!(theme.cursor.fg, Some(Color::Indexed(116)));
let theme = ColorTheme::from_options("selected:117");
assert_eq!(theme.selected.fg, Some(Color::Indexed(117)));
let theme = ColorTheme::from_options("header:118");
assert_eq!(theme.header.fg, Some(Color::Indexed(118)));
let theme = ColorTheme::from_options("border:119");
assert_eq!(theme.border.fg, Some(Color::Indexed(119)));
}
#[test]
fn test_invalid_color_graceful_handling() {
// Test that invalid color values don't crash
// When color is invalid (not a number), it returns None and the color isn't set
// So the theme starts with dark256() and the invalid color spec doesn't change it
let theme = ColorTheme::from_options("matched:invalid");
// Should remain the dark256 default since invalid color is ignored
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
// Invalid hex digits in #rrggbb format will use unwrap_or(255) fallback
// So "#gggggg" becomes Rgb(255, 255, 255) since 'gg' is invalid hex
let theme = ColorTheme::from_options("matched:#gggggg");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 255, 255)));
// But the background remains from dark256 theme since we only set fg
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
}
#[test]
fn test_init_from_options() {
// Test initialization from SkimOptions
let opts = crate::options::SkimOptionsBuilder::default()
.color("matched:108")
.build()
.unwrap();
let theme = ColorTheme::init_from_options(&opts);
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
}
#[test]
fn test_complex_color_spec() {
// Test a complex real-world color specification
let theme =
ColorTheme::from_options("dark,matched:#00ff00:bold,prompt:#0000ff:underlined,current:#ffff00:italic");
assert_eq!(theme.matched.fg, Some(Color::Rgb(0, 255, 0)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 0, 255)));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
assert_eq!(theme.current.fg, Some(Color::Rgb(255, 255, 0)));
assert!(theme.current.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn test_minus_one_color_reset() {
// `hl:-1:reverse` should not have foreground or background color, but keep the reverse modifier
let theme = ColorTheme::from_options("dark,hl:-1:reverse,hl-bg:-1,hl+:-1:bold,bg+:-1");
assert_eq!(theme.matched.fg, Some(Color::Reset));
assert_eq!(theme.matched.bg, Some(Color::Reset));
assert!(theme.matched.add_modifier.contains(Modifier::REVERSED));
assert_eq!(theme.current_match.fg, Some(Color::Reset));
assert_ne!(theme.current_match.bg, Some(Color::Reset));
assert!(theme.current_match.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("dark,prompt:-1:underlined");
assert_eq!(theme.prompt.fg, Some(Color::Reset));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("dark,bg+:-1");
assert_eq!(theme.current.bg, Some(Color::Reset));
}
}
#[path = "theme_tests.rs"]
mod tests;

429
src/theme_tests.rs Normal file
View file

@ -0,0 +1,429 @@
use super::*;
#[test]
fn test_base_themes() {
// Test that base themes have expected properties
let none = ColorTheme::none();
// Spinner should be bold even in none theme
assert!(none.spinner.add_modifier.contains(Modifier::BOLD));
let bw = ColorTheme::bw();
assert!(bw.matched.add_modifier.contains(Modifier::UNDERLINED));
assert!(bw.current.add_modifier.contains(Modifier::REVERSED));
let theme_16 = ColorTheme::default16();
assert_eq!(theme_16.matched.fg, Some(Color::Green));
assert_eq!(theme_16.matched.bg, None);
let dark = ColorTheme::dark256();
assert_eq!(dark.matched.fg, Some(Color::Indexed(108)));
assert_eq!(dark.matched.bg, Some(Color::Indexed(0)));
let molokai = ColorTheme::molokai256();
assert_eq!(molokai.matched.fg, Some(Color::Indexed(234)));
assert_eq!(molokai.matched.bg, Some(Color::Indexed(186)));
let light = ColorTheme::light256();
assert_eq!(light.matched.fg, Some(Color::Indexed(0)));
assert_eq!(light.matched.bg, Some(Color::Indexed(220)));
}
#[test]
fn test_from_options_base_themes() {
// Test base theme names
let dark = ColorTheme::from_options("dark");
assert!(dark.matched.fg.is_some());
let molokai = ColorTheme::from_options("molokai");
assert!(molokai.matched.fg.is_some());
let light = ColorTheme::from_options("light");
assert!(light.matched.fg.is_some());
let theme_16 = ColorTheme::from_options("16");
assert!(theme_16.matched.fg.is_some());
let bw = ColorTheme::from_options("bw");
assert!(bw.matched.add_modifier.contains(Modifier::UNDERLINED));
// Test that "none" theme uses reset style (which may have default colors from terminal)
let none = ColorTheme::from_options("none");
// Spinner should still be bold even in none theme
assert!(none.spinner.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_ansi_color_parsing() {
// Test ANSI color (0-255)
let theme = ColorTheme::from_options("matched:108");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("prompt:25");
assert_eq!(theme.prompt.fg, Some(Color::Indexed(25)));
}
#[test]
fn test_rgb_hex_color_parsing() {
// Test RGB hex color (#rrggbb)
let theme = ColorTheme::from_options("matched:#ff0000");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 0, 0)));
let theme = ColorTheme::from_options("prompt:#00ff00");
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 255, 0)));
let theme = ColorTheme::from_options("info:#0000ff");
assert_eq!(theme.info.fg, Some(Color::Rgb(0, 0, 255)));
}
#[test]
fn test_color_with_modifiers() {
// Test color with bold modifier
let theme = ColorTheme::from_options("matched:108:bold");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
// Test color with underline modifier
let theme = ColorTheme::from_options("matched:108:underlined");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
// Test color with multiple modifiers (using +)
let theme = ColorTheme::from_options("matched:108:bold:underlined");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_modifier_shortcuts() {
// Test short modifier names
let theme = ColorTheme::from_options("matched:108:b");
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("matched:108:u");
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("matched:108:i");
assert!(theme.matched.add_modifier.contains(Modifier::ITALIC));
let theme = ColorTheme::from_options("matched:108:r");
assert!(theme.matched.add_modifier.contains(Modifier::REVERSED));
let theme = ColorTheme::from_options("matched:108:d");
assert!(theme.matched.add_modifier.contains(Modifier::DIM));
let theme = ColorTheme::from_options("matched:108:c");
assert!(theme.matched.add_modifier.contains(Modifier::CROSSED_OUT));
}
#[test]
fn test_regular_modifier_reset() {
// Test that 'regular' or 'x' resets modifiers
let theme = ColorTheme::from_options("matched:108:x:bold");
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert!(!theme.matched.add_modifier.contains(Modifier::ITALIC));
let theme = ColorTheme::from_options("matched:108:regular:underlined");
assert!(theme.matched.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_multiple_color_components() {
// Test multiple color components separated by comma
let theme = ColorTheme::from_options("matched:108,prompt:25");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert_eq!(theme.prompt.fg, Some(Color::Indexed(25)));
let theme = ColorTheme::from_options("matched:#ff0000:bold,prompt:#00ff00:underlined");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 0, 0)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 255, 0)));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_component_name_aliases() {
// Test that aliases work correctly
let theme = ColorTheme::from_options("hl:108");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("fg+:254");
assert_eq!(theme.current.fg, Some(Color::Indexed(254)));
let theme = ColorTheme::from_options("bg+:236");
assert_eq!(theme.current.bg, Some(Color::Indexed(236)));
let theme = ColorTheme::from_options("hl+:151");
// hl+ is an alias for current_match
assert_eq!(theme.current_match.fg, Some(Color::Indexed(151)));
let theme = ColorTheme::from_options("pointer:161");
assert_eq!(theme.cursor.fg, Some(Color::Indexed(161)));
let theme = ColorTheme::from_options("marker:168");
assert_eq!(theme.selected.fg, Some(Color::Indexed(168)));
}
#[test]
fn test_background_color() {
// Test setting background color explicitly
let theme = ColorTheme::from_options("matched_bg:0");
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
let theme = ColorTheme::from_options("matched-bg:236");
assert_eq!(theme.matched.bg, Some(Color::Indexed(236)));
}
#[test]
fn test_default_theme_with_overrides() {
// Test overriding default theme
let theme = ColorTheme::from_options("default,matched:200");
assert_eq!(theme.matched.fg, Some(Color::Indexed(200)));
// Other colors should still be from default theme
assert!(theme.prompt.fg.is_some());
}
#[test]
fn test_theme_with_overrides() {
// Test overriding theme
for opts in &["16,prompt:200", "prompt:150,16,prompt:200"] {
let theme = ColorTheme::from_options(opts);
assert_eq!(theme.prompt.fg, Some(Color::Indexed(200)));
// Other colors should still be from given theme
assert_eq!(theme.matched.fg, Some(Color::Green));
assert_eq!(theme.matched.bg, None);
}
}
#[test]
fn test_all_component_names() {
// Test all valid component names with their specific colors
let theme = ColorTheme::from_options("normal:108");
assert_eq!(theme.normal.fg, Some(Color::Indexed(108)));
let theme = ColorTheme::from_options("matched:109");
assert_eq!(theme.matched.fg, Some(Color::Indexed(109)));
let theme = ColorTheme::from_options("current:110");
assert_eq!(theme.current.fg, Some(Color::Indexed(110)));
let theme = ColorTheme::from_options("current_match:111");
// current_match should now correctly set current_match.fg
assert_eq!(theme.current_match.fg, Some(Color::Indexed(111)));
let theme = ColorTheme::from_options("query:112");
assert_eq!(theme.query.fg, Some(Color::Indexed(112)));
let theme = ColorTheme::from_options("spinner:113");
assert_eq!(theme.spinner.fg, Some(Color::Indexed(113)));
let theme = ColorTheme::from_options("info:114");
assert_eq!(theme.info.fg, Some(Color::Indexed(114)));
let theme = ColorTheme::from_options("prompt:115");
assert_eq!(theme.prompt.fg, Some(Color::Indexed(115)));
let theme = ColorTheme::from_options("cursor:116");
assert_eq!(theme.cursor.fg, Some(Color::Indexed(116)));
let theme = ColorTheme::from_options("selected:117");
assert_eq!(theme.selected.fg, Some(Color::Indexed(117)));
let theme = ColorTheme::from_options("header:118");
assert_eq!(theme.header.fg, Some(Color::Indexed(118)));
let theme = ColorTheme::from_options("border:119");
assert_eq!(theme.border.fg, Some(Color::Indexed(119)));
}
#[test]
fn test_invalid_color_graceful_handling() {
// Test that invalid color values don't crash
// When color is invalid (not a number), it returns None and the color isn't set
// So the theme starts with dark256() and the invalid color spec doesn't change it
let theme = ColorTheme::from_options("matched:invalid");
// Should remain the dark256 default since invalid color is ignored
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
// Invalid hex digits in #rrggbb format will use unwrap_or(255) fallback
// So "#gggggg" becomes Rgb(255, 255, 255) since 'gg' is invalid hex
let theme = ColorTheme::from_options("matched:#gggggg");
assert_eq!(theme.matched.fg, Some(Color::Rgb(255, 255, 255)));
// But the background remains from dark256 theme since we only set fg
assert_eq!(theme.matched.bg, Some(Color::Indexed(0)));
}
#[test]
fn test_init_from_options() {
// Test initialization from SkimOptions
let opts = crate::options::SkimOptionsBuilder::default()
.color("matched:108")
.build()
.unwrap();
let theme = ColorTheme::init_from_options(&opts);
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
}
#[test]
fn test_complex_color_spec() {
// Test a complex real-world color specification
let theme = ColorTheme::from_options("dark,matched:#00ff00:bold,prompt:#0000ff:underlined,current:#ffff00:italic");
assert_eq!(theme.matched.fg, Some(Color::Rgb(0, 255, 0)));
assert!(theme.matched.add_modifier.contains(Modifier::BOLD));
assert_eq!(theme.prompt.fg, Some(Color::Rgb(0, 0, 255)));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
assert_eq!(theme.current.fg, Some(Color::Rgb(255, 255, 0)));
assert!(theme.current.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn test_minus_one_color_reset() {
// `hl:-1:reverse` should not have foreground or background color, but keep the reverse modifier
let theme = ColorTheme::from_options("dark,hl:-1:reverse,hl-bg:-1,hl+:-1:bold,bg+:-1");
assert_eq!(theme.matched.fg, Some(Color::Reset));
assert_eq!(theme.matched.bg, Some(Color::Reset));
assert!(theme.matched.add_modifier.contains(Modifier::REVERSED));
assert_eq!(theme.current_match.fg, Some(Color::Reset));
assert_ne!(theme.current_match.bg, Some(Color::Reset));
assert!(theme.current_match.add_modifier.contains(Modifier::BOLD));
let theme = ColorTheme::from_options("dark,prompt:-1:underlined");
assert_eq!(theme.prompt.fg, Some(Color::Reset));
assert!(theme.prompt.add_modifier.contains(Modifier::UNDERLINED));
let theme = ColorTheme::from_options("dark,bg+:-1");
assert_eq!(theme.current.bg, Some(Color::Reset));
}
#[test]
fn test_catppuccin_themes_have_colors() {
for theme in [
ColorTheme::catppuccin_mocha(),
ColorTheme::catppuccin_macchiato(),
ColorTheme::catppuccin_latte(),
ColorTheme::catppuccin_frappe(),
] {
assert!(theme.matched.fg.is_some());
assert!(theme.current.bg.is_some());
}
}
#[test]
fn test_from_options_catppuccin_aliases() {
// Both underscore and hyphen spellings must resolve to a populated theme.
for name in [
"catppuccin_mocha",
"catppuccin-mocha",
"catppuccin_macchiato",
"catppuccin-macchiato",
"catppuccin_latte",
"catppuccin-latte",
"catppuccin_frappe",
"catppuccin-frappe",
] {
let theme = ColorTheme::from_options(name);
assert!(theme.matched.fg.is_some(), "theme {name} should have a matched fg");
}
}
#[test]
fn test_from_options_default_and_empty_aliases() {
let default = ColorTheme::from_options("default");
assert_eq!(default.matched.fg, ColorTheme::dark256().matched.fg);
let empty = ColorTheme::from_options("empty");
assert!(empty.spinner.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_from_options_unknown_falls_back_to_dark() {
let unknown = ColorTheme::from_options("this-is-not-a-real-theme");
assert_eq!(unknown.matched.fg, ColorTheme::dark256().matched.fg);
}
#[test]
fn test_unknown_modifier_is_ignored() {
// An unrecognised modifier name is dropped without affecting the color.
let theme = ColorTheme::from_options("matched:108:notamodifier");
assert_eq!(theme.matched.fg, Some(Color::Indexed(108)));
}
#[test]
fn test_explicit_fg_layer_suffix() {
let theme = ColorTheme::from_options("matched_fg:5");
assert_eq!(theme.matched.fg, Some(Color::Indexed(5)));
let theme = ColorTheme::from_options("matched-fg:6");
assert_eq!(theme.matched.fg, Some(Color::Indexed(6)));
}
#[test]
fn test_underline_layer_suffixes() {
// `_u` / `-u` and the long `_underline` form set the underline color.
let theme = ColorTheme::from_options("matched_u:5");
assert_eq!(theme.matched.underline_color, Some(Color::Indexed(5)));
let theme = ColorTheme::from_options("matched-u:6");
assert_eq!(theme.matched.underline_color, Some(Color::Indexed(6)));
let theme = ColorTheme::from_options("matched_underline:7");
assert_eq!(theme.matched.underline_color, Some(Color::Indexed(7)));
let theme = ColorTheme::from_options("matched-underline:8");
assert_eq!(theme.matched.underline_color, Some(Color::Indexed(8)));
}
#[test]
fn test_bare_bg_sets_normal_background() {
// A bare "bg" name targets the normal style's background.
let theme = ColorTheme::from_options("bg:5");
assert_eq!(theme.normal.bg, Some(Color::Indexed(5)));
}
#[test]
fn test_unknown_component_name_is_noop() {
// An unknown component returns early, leaving the dark256 default intact.
let theme = ColorTheme::from_options("not_a_component:5");
assert_eq!(theme.matched.fg, ColorTheme::dark256().matched.fg);
}
struct EnvGuard {
key: &'static str,
prior: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let prior = std::env::var_os(key);
// SAFETY: caller must hold a serial lock so no other thread reads this var.
unsafe { std::env::set_var(key, value) };
Self { key, prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: same serial-lock guarantee as set().
unsafe {
match &self.prior {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
#[serial_test::serial]
fn test_init_from_options_respects_no_color() {
let _guard = EnvGuard::set("NO_COLOR", "1");
let opts = crate::options::SkimOptionsBuilder::default().build().unwrap();
let theme = ColorTheme::init_from_options(&opts);
// NO_COLOR yields the `none` theme, matching the bare none() palette.
assert_eq!(theme.matched.fg, ColorTheme::none().matched.fg);
}
#[test]
#[serial_test::serial]
fn test_init_from_options_empty_no_color_uses_default() {
let _guard = EnvGuard::set("NO_COLOR", "");
let opts = crate::options::SkimOptionsBuilder::default().build().unwrap();
let theme = ColorTheme::init_from_options(&opts);
// An empty NO_COLOR is ignored, so the dark256 default applies.
assert_eq!(theme.matched.fg, ColorTheme::dark256().matched.fg);
}

View file

@ -493,199 +493,5 @@ impl AtomicCounter {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partition_threads_split() {
// Single-core: both pools get at least 1 thread.
assert_eq!(partition_threads(1), (1, 1));
// Two cores: 1 reader, 1 matcher.
assert_eq!(partition_threads(2), (1, 1));
// Three cores: 1 reader, 2 matcher.
assert_eq!(partition_threads(3), (1, 2));
// Six cores: 2 reader, 4 matcher.
assert_eq!(partition_threads(6), (2, 4));
// Eight cores: 3 reader, 5 matcher; sums to 8.
assert_eq!(partition_threads(8), (3, 5));
// Nine cores: 3 reader, 6 matcher; sums to 9.
assert_eq!(partition_threads(9), (3, 6));
// The two values always sum to n for n >= 3.
for n in 3..=64 {
let (r, m) = partition_threads(n);
assert_eq!(r + m, n, "partition_threads({n}) = ({r}, {m}) does not sum to {n}");
assert!(r >= 1);
assert!(m >= 1);
}
}
#[test]
fn spawn_runs_closure() {
let pool = ThreadPool::new(2);
let flag = Arc::new(AtomicUsize::new(0));
let flag2 = Arc::clone(&flag);
pool.spawn(move || {
flag2.store(42, Ordering::SeqCst);
});
// Give it a moment.
std::thread::sleep(std::time::Duration::from_millis(50));
assert_eq!(flag.load(Ordering::SeqCst), 42);
}
#[test]
fn spawn_batch_runs_all() {
let pool = ThreadPool::new(4);
let counter = Arc::new(AtomicUsize::new(0));
let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..10)
.map(|_| {
let c = Arc::clone(&counter);
let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || {
c.fetch_add(1, Ordering::SeqCst);
});
job
})
.collect();
pool.spawn_batch(jobs);
std::thread::sleep(std::time::Duration::from_millis(100));
assert_eq!(counter.load(Ordering::SeqCst), 10);
}
#[test]
fn parallel_work_queue_sums() {
let pool = ThreadPool::new(4);
let items: Arc<[u64]> = (1..=1000u64).collect::<Vec<_>>().into();
let mut result = 0u64;
parallel_work_queue(
&pool,
4,
&items,
64,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
for partial in worker_results {
result += partial;
}
},
);
assert_eq!(result, 500_500);
}
#[test]
fn parallel_work_queue_empty() {
let pool = ThreadPool::new(2);
let items: Arc<[u64]> = Arc::from(Vec::<u64>::new().into_boxed_slice());
let mut result = Vec::<u64>::new();
parallel_work_queue(
&pool,
2,
&items,
64,
Vec::<u64>::new,
|_start, chunk| chunk.to_vec(),
|acc, mut partial| acc.append(&mut partial),
|_| {},
|worker_results| {
for partial in worker_results {
result.extend(partial);
}
},
);
assert!(result.is_empty());
}
#[test]
fn parallel_work_queue_single_thread() {
let pool = ThreadPool::new(1);
let items: Arc<[i32]> = (0..100i32).collect::<Vec<_>>().into();
let mut result = 0i32;
parallel_work_queue(
&pool,
1,
&items,
10,
|| 0i32,
|_start, chunk| chunk.iter().sum::<i32>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
for partial in worker_results {
result += partial;
}
},
);
assert_eq!(result, (0..100).sum::<i32>());
}
#[test]
fn pool_drop_joins_threads() {
let flag = Arc::new(AtomicUsize::new(0));
{
let pool = ThreadPool::new(2);
let f = Arc::clone(&flag);
pool.spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
f.store(1, Ordering::SeqCst);
});
} // pool dropped here should join
assert_eq!(flag.load(Ordering::SeqCst), 1);
}
#[test]
fn parallel_work_queue_many_workers_few_chunks() {
// More workers than chunks — extra workers should gracefully no-op.
let pool = ThreadPool::new(8);
let items: Arc<[u64]> = (1..=10u64).collect::<Vec<_>>().into();
let mut result = 0u64;
parallel_work_queue(
&pool,
8,
&items,
5,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
for partial in worker_results {
result += partial;
}
},
);
assert_eq!(result, 55);
}
#[test]
fn parallel_work_queue_single_thread_pool_no_deadlock() {
// With a 1-thread pool the coordinator must NOT be a pool job —
// it runs on a dedicated OS thread and submits all worker jobs to
// the pool. This ensures the single pool thread is always free to
// run those workers and no deadlock can occur.
let (tx, rx) = std::sync::mpsc::channel();
let pool = Arc::new(ThreadPool::new(1));
let items: Arc<[u64]> = (1..=100u64).collect::<Vec<_>>().into();
let pool_coord = Arc::clone(&pool);
// Coordinator is a dedicated thread, not a pool job.
std::thread::spawn(move || {
parallel_work_queue(
&pool_coord,
1,
&items,
10,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
let _ = tx.send(worker_results.into_iter().sum::<u64>());
},
);
});
let result = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("deadlock or timeout");
assert_eq!(result, 5050);
}
}
#[path = "thread_pool_tests.rs"]
mod tests;

202
src/thread_pool_tests.rs Normal file
View file

@ -0,0 +1,202 @@
use super::*;
#[test]
fn partition_threads_split() {
// Single-core: both pools get at least 1 thread.
assert_eq!(partition_threads(1), (1, 1));
// Two cores: 1 reader, 1 matcher.
assert_eq!(partition_threads(2), (1, 1));
// Three cores: 1 reader, 2 matcher.
assert_eq!(partition_threads(3), (1, 2));
// Six cores: 2 reader, 4 matcher.
assert_eq!(partition_threads(6), (2, 4));
// Eight cores: 3 reader, 5 matcher; sums to 8.
assert_eq!(partition_threads(8), (3, 5));
// Nine cores: 3 reader, 6 matcher; sums to 9.
assert_eq!(partition_threads(9), (3, 6));
// The two values always sum to n for n >= 3.
for n in 3..=64 {
let (r, m) = partition_threads(n);
assert_eq!(r + m, n, "partition_threads({n}) = ({r}, {m}) does not sum to {n}");
assert!(r >= 1);
assert!(m >= 1);
}
}
#[test]
fn spawn_runs_closure() {
let pool = ThreadPool::new(2);
let flag = Arc::new(AtomicUsize::new(0));
let flag2 = Arc::clone(&flag);
let (tx, rx) = std::sync::mpsc::channel();
pool.spawn(move || {
flag2.store(42, Ordering::SeqCst);
let _ = tx.send(());
});
rx.recv_timeout(std::time::Duration::from_secs(5))
.expect("closure did not complete");
assert_eq!(flag.load(Ordering::SeqCst), 42);
}
#[test]
fn spawn_batch_runs_all() {
let pool = ThreadPool::new(4);
let counter = Arc::new(AtomicUsize::new(0));
let (tx, rx) = std::sync::mpsc::channel();
let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..10)
.map(|_| {
let c = Arc::clone(&counter);
let tx = tx.clone();
let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || {
c.fetch_add(1, Ordering::SeqCst);
let _ = tx.send(());
});
job
})
.collect();
pool.spawn_batch(jobs);
for _ in 0..10 {
rx.recv_timeout(std::time::Duration::from_secs(5))
.expect("job did not complete");
}
assert_eq!(counter.load(Ordering::SeqCst), 10);
}
#[test]
fn parallel_work_queue_sums() {
let pool = ThreadPool::new(4);
let items: Arc<[u64]> = (1..=1000u64).collect::<Vec<_>>().into();
let mut result = 0u64;
parallel_work_queue(
&pool,
4,
&items,
64,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
for partial in worker_results {
result += partial;
}
},
);
assert_eq!(result, 500_500);
}
#[test]
fn parallel_work_queue_empty() {
let pool = ThreadPool::new(2);
let items: Arc<[u64]> = Arc::from(Vec::<u64>::new().into_boxed_slice());
let mut result = Vec::<u64>::new();
parallel_work_queue(
&pool,
2,
&items,
64,
Vec::<u64>::new,
|_start, chunk| chunk.to_vec(),
|acc, mut partial| acc.append(&mut partial),
|_| {},
|worker_results| {
for partial in worker_results {
result.extend(partial);
}
},
);
assert!(result.is_empty());
}
#[test]
fn parallel_work_queue_single_thread() {
let pool = ThreadPool::new(1);
let items: Arc<[i32]> = (0..100i32).collect::<Vec<_>>().into();
let mut result = 0i32;
parallel_work_queue(
&pool,
1,
&items,
10,
|| 0i32,
|_start, chunk| chunk.iter().sum::<i32>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
for partial in worker_results {
result += partial;
}
},
);
assert_eq!(result, (0..100).sum::<i32>());
}
#[test]
fn pool_drop_joins_threads() {
let flag = Arc::new(AtomicUsize::new(0));
{
let pool = ThreadPool::new(2);
let f = Arc::clone(&flag);
pool.spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
f.store(1, Ordering::SeqCst);
});
} // pool dropped here should join
assert_eq!(flag.load(Ordering::SeqCst), 1);
}
#[test]
fn parallel_work_queue_many_workers_few_chunks() {
// More workers than chunks — extra workers should gracefully no-op.
let pool = ThreadPool::new(8);
let items: Arc<[u64]> = (1..=10u64).collect::<Vec<_>>().into();
let mut result = 0u64;
parallel_work_queue(
&pool,
8,
&items,
5,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
for partial in worker_results {
result += partial;
}
},
);
assert_eq!(result, 55);
}
#[test]
fn parallel_work_queue_single_thread_pool_no_deadlock() {
// With a 1-thread pool the coordinator must NOT be a pool job —
// it runs on a dedicated OS thread and submits all worker jobs to
// the pool. This ensures the single pool thread is always free to
// run those workers and no deadlock can occur.
let (tx, rx) = std::sync::mpsc::channel();
let pool = Arc::new(ThreadPool::new(1));
let items: Arc<[u64]> = (1..=100u64).collect::<Vec<_>>().into();
let pool_coord = Arc::clone(&pool);
// Coordinator is a dedicated thread, not a pool job.
std::thread::spawn(move || {
parallel_work_queue(
&pool_coord,
1,
&items,
10,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
|_| {},
|worker_results| {
let _ = tx.send(worker_results.into_iter().sum::<u64>());
},
);
});
let result = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("deadlock or timeout");
assert_eq!(result, 5050);
}

View file

@ -14,6 +14,10 @@ use crate::tui::widget::SkimWidget;
use crate::tui::{SkimRender, TICK_RATE};
use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
#[cfg(test)]
#[path = "app_tests.rs"]
mod tests;
use super::event::Action;
use super::header::Header;
use super::item_list::ItemList;
@ -789,27 +793,34 @@ impl App {
self.input.move_to_end();
}
Execute(cmd) => {
use std::io::IsTerminal as _;
let expanded_cmd = self.expand_cmd(cmd, true);
debug!("execute: {expanded_cmd}");
let mut command = crate::shell_cmd(&expanded_cmd);
let in_raw_mode = crossterm::terminal::is_raw_mode_enabled()?;
if in_raw_mode {
crossterm::terminal::disable_raw_mode()?;
let has_tty = std::io::stderr().is_terminal();
let in_raw_mode = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
if has_tty {
if in_raw_mode {
crossterm::terminal::disable_raw_mode()?;
}
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture
)?;
}
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture
)?;
let _ = command.spawn().and_then(|mut c| c.wait());
if in_raw_mode {
crossterm::terminal::enable_raw_mode()?;
if has_tty {
if in_raw_mode {
crossterm::terminal::enable_raw_mode()?;
}
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
}
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
return Ok(vec![Event::Redraw]);
}
ExecuteSilent(cmd) => {

1573
src/tui/app_tests.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -304,3 +304,48 @@ pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
crossterm::terminal::disable_raw_mode()?;
Ok(())
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
fn fullscreen_tui() -> Tui<TestBackend> {
// Percent(100) selects the fullscreen viewport, avoiding any TTY cursor query.
Tui::new_with_height_and_backend(TestBackend::new(80, 24), Size::Percent(100))
.expect("failed to build test TUI")
}
#[test]
fn new_with_full_height_is_fullscreen() {
let tui = fullscreen_tui();
assert!(tui.is_fullscreen);
assert!(tui.enable_mouse);
}
#[test]
fn stop_cancels_token() {
let tui = fullscreen_tui();
assert!(!tui.cancellation_token.is_cancelled());
tui.stop();
assert!(tui.cancellation_token.is_cancelled());
}
#[test]
fn cancel_is_idempotent() {
let tui = fullscreen_tui();
tui.cancel();
tui.cancel();
assert!(tui.cancellation_token.is_cancelled());
}
#[test]
fn deref_exposes_terminal_frame() {
let mut tui = fullscreen_tui();
// Deref/DerefMut should expose the underlying ratatui terminal.
let area = tui.get_frame().area();
assert_eq!(area.width, 80);
assert_eq!(area.height, 24);
}
}

View file

@ -428,3 +428,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
}
}
}
#[cfg(test)]
#[path = "event_tests.rs"]
mod tests;

190
src/tui/event_tests.rs Normal file
View file

@ -0,0 +1,190 @@
use super::*;
const NO_ARG_ACTIONS: &[&str] = &[
"abort",
"append-and-select",
"backward-char",
"backward-delete-char",
"backward-delete-char/eof",
"backward-kill-word",
"backward-word",
"beginning-of-line",
"cancel",
"clear-screen",
"delete-char",
"delete-char/eof",
"deselect-all",
"end-of-line",
"first",
"forward-char",
"forward-word",
"ignore",
"kill-line",
"kill-word",
"last",
"next-history",
"previous-history",
"redraw",
"refresh-cmd",
"refresh-preview",
"restart-matcher",
"rotate-mode",
"select",
"select-all",
"toggle",
"toggle-all",
"toggle-in",
"toggle-interactive",
"toggle-out",
"toggle-preview",
"toggle-preview-wrap",
"toggle-sort",
"top",
"unix-line-discard",
"unix-word-rubout",
"yank",
];
#[test]
fn parse_all_no_arg_actions() {
for name in NO_ARG_ACTIONS {
assert!(parse_action(name).is_some(), "expected `{name}` to parse");
}
}
#[test]
fn parse_numeric_actions_default_to_one() {
assert_eq!(parse_action("down"), Some(Action::Down(1)));
assert_eq!(parse_action("up"), Some(Action::Up(1)));
assert_eq!(parse_action("page-down"), Some(Action::PageDown(1)));
assert_eq!(parse_action("scroll-left"), Some(Action::ScrollLeft(1)));
assert_eq!(parse_action("select-row"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_numeric_actions_with_colon_arg() {
assert_eq!(parse_action("down:3"), Some(Action::Down(3)));
assert_eq!(parse_action("up:5"), Some(Action::Up(5)));
assert_eq!(parse_action("half-page-down:2"), Some(Action::HalfPageDown(2)));
assert_eq!(parse_action("preview-up:4"), Some(Action::PreviewUp(4)));
assert_eq!(parse_action("select-row:7"), Some(Action::SelectRow(7)));
}
#[test]
fn parse_numeric_actions_with_paren_arg() {
assert_eq!(parse_action("down(3)"), Some(Action::Down(3)));
assert_eq!(parse_action("scroll-right(2)"), Some(Action::ScrollRight(2)));
}
#[test]
fn parse_string_arg_actions() {
assert_eq!(
parse_action("execute:ls -la"),
Some(Action::Execute("ls -la".to_string()))
);
assert_eq!(
parse_action("execute-silent:touch x"),
Some(Action::ExecuteSilent("touch x".to_string()))
);
assert_eq!(
parse_action("set-query:hello"),
Some(Action::SetQuery("hello".to_string()))
);
assert_eq!(
parse_action("set-preview-cmd:cat {}"),
Some(Action::SetPreviewCmd("cat {}".to_string()))
);
assert_eq!(parse_action("add-char:z"), Some(Action::AddChar('z')));
}
#[test]
fn parse_optional_arg_actions() {
assert_eq!(parse_action("accept"), Some(Action::Accept(None)));
assert_eq!(
parse_action("accept:enter"),
Some(Action::Accept(Some("enter".to_string())))
);
assert_eq!(parse_action("set-header"), Some(Action::SetHeader(None)));
assert_eq!(parse_action("reload"), Some(Action::Reload(None)));
assert_eq!(
parse_action("reload:find ."),
Some(Action::Reload(Some("find .".to_string())))
);
}
#[test]
fn parse_if_chains_then_only() {
assert_eq!(
parse_action("if-query-empty:abort"),
Some(Action::IfQueryEmpty("abort".to_string(), None))
);
assert_eq!(
parse_action("if-non-matched:ignore"),
Some(Action::IfNonMatched("ignore".to_string(), None))
);
}
#[test]
fn parse_if_chains_then_and_else() {
assert_eq!(
parse_action("if-query-not-empty:abort+ignore"),
Some(Action::IfQueryNotEmpty("abort".to_string(), Some("ignore".to_string())))
);
}
#[test]
fn parse_numeric_action_with_invalid_arg_falls_back_to_default() {
// A non-numeric argument is ignored and the default count is used.
assert_eq!(parse_action("down:abc"), Some(Action::Down(1)));
assert_eq!(parse_action("page-up:xyz"), Some(Action::PageUp(1)));
// SelectRow defaults to 0 rather than 1.
assert_eq!(parse_action("select-row:nope"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_unknown_action_returns_none() {
assert_eq!(parse_action("not-a-real-action"), None);
}
#[test]
fn parse_action_trailing_separator_yields_no_arg() {
// A separator with nothing after it (`act:`) is treated as if no argument
// was supplied, so optional-arg actions fall back to their `None` form
// rather than being handed an empty string.
assert_eq!(parse_action("accept:"), Some(Action::Accept(None)));
assert_eq!(parse_action("reload:"), Some(Action::Reload(None)));
assert_eq!(parse_action("set-header:"), Some(Action::SetHeader(None)));
// Numeric actions fall back to their default count for the same reason.
assert_eq!(parse_action("down:"), Some(Action::Down(1)));
assert_eq!(parse_action("select-row:"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_if_chain_with_trailing_plus_has_empty_else() {
// A trailing `+` yields a then-branch with no otherwise-branch.
assert_eq!(
parse_action("if-query-empty:abort+"),
Some(Action::IfQueryEmpty("abort".to_string(), None))
);
}
#[test]
fn parse_if_chain_unknown_kind_returns_none() {
// An `if-` prefixed action that is not one of the known kinds is rejected.
assert_eq!(parse_action("if-bogus:abort"), None);
}
#[test]
fn action_callback_debug_is_opaque() {
let cb = ActionCallback::new_sync(|_app| Ok(vec![]));
assert_eq!(format!("{cb:?}"), "ActionCallback");
}
#[test]
fn action_callback_async_constructor_builds() {
// The async constructor wraps the closure without invoking it.
let cb = ActionCallback::new(|_app| async move { Ok(vec![Event::Render]) });
// Cloning shares the same inner callback.
let _clone = cb.clone();
assert_eq!(format!("{cb:?}"), "ActionCallback");
}

View file

@ -233,3 +233,95 @@ impl SkimWidget for Header {
SkimRender::default()
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn header_with(options: &SkimOptions) -> Header {
Header::from_options(options, Arc::new(ColorTheme::default()))
}
fn buffer_text(buf: &Buffer) -> String {
let area = buf.area;
let mut out = String::new();
for y in 0..area.height {
for x in 0..area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn apply_tabstop_expands_to_column() {
// A tab advances to the next multiple of the tabstop width.
assert_eq!(apply_tabstop("a\tb", 4), "a b");
assert_eq!(apply_tabstop("\t", 4), " ");
assert_eq!(apply_tabstop("ab\tc", 4), "ab c");
assert_eq!(apply_tabstop("noTabs", 8), "noTabs");
}
#[test]
fn default_header_is_empty_with_zero_height() {
let header = Header::default();
assert_eq!(header.height(), 0);
}
#[test]
fn theme_setter_is_chainable() {
let header = Header::default().theme(Arc::new(ColorTheme::default()));
assert_eq!(header.height(), 0);
}
#[test]
fn height_counts_static_header_lines() {
let options = SkimOptionsBuilder::default()
.header("line one\nline two")
.build()
.unwrap();
let header = header_with(&options);
assert_eq!(header.height(), 2);
}
#[test]
fn height_includes_reserved_header_lines_count() {
let options = SkimOptionsBuilder::default().header_lines(3usize).build().unwrap();
let header = header_with(&options);
// No items yet → falls back to the reserved count.
assert_eq!(header.height(), 3);
}
#[test]
fn set_header_lines_counts_dynamic_items() {
let options = SkimOptionsBuilder::default().header_lines(2usize).build().unwrap();
let mut header = header_with(&options);
let items: Vec<Arc<dyn SkimItem>> = vec![Arc::new("a".to_string()), Arc::new("b".to_string())];
header.set_header_lines(items);
assert_eq!(header.height(), 2);
}
#[test]
fn render_writes_static_header_text() {
let options = SkimOptionsBuilder::default().header("MYHEADER").build().unwrap();
let mut header = header_with(&options);
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
header.render(area, &mut buf);
assert!(buffer_text(&buf).contains("MYHEADER"));
}
#[test]
fn render_empty_header_does_not_panic() {
let mut header = Header::default();
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
// Exercises the blank-line padding branch for an empty header.
let _ = header.render(area, &mut buf);
}
}

View file

@ -65,7 +65,7 @@ impl StatusInfo {
// Matcher mode
if !self.matcher_mode.is_empty() {
let _ = write!(parts, "/{}", &self.matcher_mode);
let _ = write!(parts, "/{}", self.matcher_mode);
}
// Progress percentage
@ -108,7 +108,7 @@ impl StatusInfo {
// Matcher mode
if !self.matcher_mode.is_empty() {
let _ = write!(parts, "/{}", &self.matcher_mode);
let _ = write!(parts, "/{}", self.matcher_mode);
}
// Progress percentage
@ -561,3 +561,7 @@ impl DerefMut for Input {
&mut self.value
}
}
#[cfg(test)]
#[path = "input_tests.rs"]
mod tests;

238
src/tui/input_tests.rs Normal file
View file

@ -0,0 +1,238 @@
use super::*;
fn status() -> StatusInfo {
StatusInfo {
total: 100,
matched: 42,
processed: 50,
show_spinner: false,
matcher_mode: String::new(),
multi_selection: false,
selected: 0,
current_item_idx: 7,
hscroll_offset: 3,
start: None,
inline_separator: " < ".to_string(),
}
}
#[test]
fn left_title_without_spinner_has_padding() {
let s = status();
let title = s.left_title();
// No spinner → two leading spaces, then matched/total.
assert!(title.starts_with(" "));
assert!(title.contains("42/100"));
}
#[test]
fn left_title_with_spinner_mode_progress_and_selection() {
let mut s = status();
s.show_spinner = true;
s.start = Some(Instant::now());
s.matcher_mode = "RE".to_string();
s.multi_selection = true;
s.selected = 4;
let title = s.left_title();
assert!(title.contains("42/100"));
assert!(title.contains("/RE"));
// processed != total → progress percentage shown.
assert!(title.contains("(50%)"));
assert!(title.contains("[4]"));
}
#[test]
fn inline_separator_switches_with_spinner() {
let mut s = status();
// Spinner off → raw separator.
assert_eq!(s.inline_separator_or_spinner(), " < ");
// Spinner on → a spinner glyph plus padding the width of the separator.
s.show_spinner = true;
s.start = Some(Instant::now());
let out = s.inline_separator_or_spinner();
assert_ne!(out, " < ");
assert!(!out.is_empty());
}
#[test]
fn inline_status_includes_mode_progress_and_selection() {
let mut s = status();
s.show_spinner = true;
s.matcher_mode = "RE".to_string();
s.multi_selection = true;
s.selected = 2;
let out = s.inline_status();
assert!(out.starts_with("42/100"));
assert!(out.contains("/RE"));
assert!(out.contains("(50%)"));
assert!(out.contains("[2]"));
}
#[test]
fn right_title_shows_index_and_hscroll() {
assert_eq!(status().right_title(), "7/3");
}
#[test]
fn input_word_navigation_and_deletion() {
let mut input = Input::default();
input.insert_str("foo bar baz");
input.move_to_end();
// Delete the trailing word into the returned string.
let deleted = input.delete_backward_word();
assert_eq!(deleted, "baz");
assert_eq!(input.value, "foo bar ");
// Move the cursor back over a word, then forward again.
input.move_cursor_backward_word();
input.move_cursor_forward_word();
// delete_to_beginning empties everything before the cursor.
input.move_to_end();
let head = input.delete_to_beginning();
assert_eq!(head, "foo bar ");
assert_eq!(input.value, "");
}
#[test]
fn input_switch_mode_swaps_value_and_prompt() {
let mut input = Input::default();
input.insert_str("query");
input.switch_mode();
// After switching, the visible value is the (empty) alternate buffer.
assert_eq!(input.value, "");
input.switch_mode();
assert_eq!(input.value, "query");
}
#[test]
fn forward_word_skips_leading_separators() {
// Cursor before a run of non-word chars: forward-word skips them then the word.
let mut input = Input::default();
input.insert_str(" ..foo bar");
input.move_cursor_to(0);
input.move_cursor_forward_word();
// Lands at the end of "foo".
assert_eq!(input.value[..input.cursor_pos as usize].trim_start(), "..foo");
}
#[test]
fn backward_word_at_start_stays_at_zero() {
let mut input = Input::default();
input.insert_str("abc");
input.move_cursor_to(0);
input.move_cursor_backward_word();
assert_eq!(input.cursor_pos, 0);
}
#[test]
fn delete_backward_word_at_start_is_empty() {
let mut input = Input::default();
input.insert_str("abc");
input.move_cursor_to(0);
assert_eq!(input.delete_backward_word(), "");
assert_eq!(input.value, "abc");
}
#[test]
fn delete_backward_word_skips_trailing_punctuation() {
// Cursor after punctuation: skip the non-word chars, then the word.
let mut input = Input::default();
input.insert_str("foo bar...");
input.move_to_end();
let deleted = input.delete_backward_word();
// The "..." and "bar" are removed together.
assert!(deleted.contains("bar"));
assert_eq!(input.value, "foo ");
}
#[test]
fn delete_backward_to_whitespace_skips_trailing_spaces() {
// Ctrl+W from after trailing whitespace removes the spaces and the word.
let mut input = Input::default();
input.insert_str("foo bar ");
input.move_to_end();
let deleted = input.delete_backward_to_whitespace();
assert!(deleted.contains("bar"));
assert_eq!(input.value, "foo ");
}
#[test]
fn delete_backward_to_whitespace_at_start_is_empty() {
let mut input = Input::default();
input.insert_str("abc");
input.move_cursor_to(0);
assert_eq!(input.delete_backward_to_whitespace(), "");
}
#[test]
fn delete_forward_word_removes_next_word() {
let mut input = Input::default();
input.insert_str("foo bar");
input.move_cursor_to(0);
let deleted = input.delete_forward_word();
assert!(deleted.contains("foo"));
}
#[test]
fn delete_forward_word_at_end_is_empty() {
// Alt+D with the cursor already at the end of the buffer deletes nothing.
let mut input = Input::default();
input.insert_str("foo bar");
input.move_to_end();
assert_eq!(input.delete_forward_word(), "");
assert_eq!(input.value, "foo bar");
}
#[test]
fn delete_forward_word_skips_leading_separators() {
// Cursor sitting on a separator: forward-word delete skips the separator
// run, then removes the following word (the leading non-word skip loop).
let mut input = Input::default();
input.insert_str("foo bar");
input.move_cursor_to(3); // on the first space of the run
let deleted = input.delete_forward_word();
assert_eq!(deleted, " bar");
assert_eq!(input.value, "foo");
}
#[test]
fn move_cursor_zero_offset_is_noop() {
let mut input = Input::default();
input.insert_str("abc");
input.move_cursor_to(1);
input.move_cursor(0);
assert_eq!(input.cursor_pos, 1);
}
#[test]
fn delete_at_bounds_returns_none() {
let mut input = Input::default();
// Empty input → nothing to delete.
assert!(input.delete(0).is_none());
input.insert_str("ab");
input.move_to_end();
// Forward-delete at end → out of range → None.
assert!(input.delete(0).is_none());
}
#[test]
fn input_render_writes_prompt_and_value() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let mut input = Input::default();
input.insert_str("hello");
input.status_info = Some(status());
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
input.render(area, &mut buf);
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(buf[(x, y)].symbol());
}
}
assert!(text.contains("hello"));
}

View file

@ -633,3 +633,7 @@ fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
sel.insert(item.clone());
}
}
#[cfg(test)]
#[path = "item_list_tests.rs"]
mod tests;

291
src/tui/item_list_tests.rs Normal file
View file

@ -0,0 +1,291 @@
#![allow(clippy::field_reassign_with_default)]
use super::*;
use crate::Rank;
use crate::item::RankBuilder;
fn matched(text: &str, index: i32) -> MatchedItem {
let item: std::sync::Arc<dyn crate::SkimItem> = std::sync::Arc::new(text.to_string());
MatchedItem::new(
item,
Rank {
index,
..Default::default()
},
None,
&RankBuilder::default(),
)
}
fn list(n: usize) -> ItemList {
let mut il = ItemList::default();
let mut items: Vec<MatchedItem> = (0..n)
.map(|i| matched(&format!("item{i}"), i32::try_from(i).unwrap()))
.collect();
il.append(&mut items);
il.height = 10;
il
}
#[test]
fn selection_and_selected_skip_disabled_items() {
use std::borrow::Cow;
// An item flagged disabled (as `--disable-pattern` does) cannot become the
// selection, so accepting it yields no output.
struct Disabled(&'static str);
impl crate::SkimItem for Disabled {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed(self.0)
}
fn disabled(&self) -> bool {
true
}
}
let rb = RankBuilder::default();
let disabled = MatchedItem::new(
std::sync::Arc::new(Disabled("foo")) as std::sync::Arc<dyn crate::SkimItem>,
Rank::default(),
None,
&rb,
);
let mut il = ItemList::default();
il.append(&mut vec![disabled, matched("bar", 1)]);
il.height = 10;
// Cursor on the disabled row: `selected()` returns nothing.
il.select_row(0); // no-op for a disabled item
assert!(il.selected().is_none());
assert!(il.selection.is_empty());
// Toggling the disabled row also adds nothing.
il.toggle_at(0);
assert!(il.selection.is_empty());
// The enabled row behaves normally.
il.toggle_at(1);
assert_eq!(il.selection.len(), 1);
assert_eq!(il.selection[0].text(), "bar");
}
#[test]
fn scroll_by_clamps_within_bounds() {
let mut il = list(5);
il.scroll_by(2);
assert_eq!(il.current, 2);
// Can't go below 0.
il.scroll_by(-10);
assert_eq!(il.current, 0);
// Can't exceed last index.
il.scroll_by(100);
assert_eq!(il.current, 4);
}
#[test]
fn scroll_by_cycles_when_enabled() {
let mut il = list(3);
il.cycle = true;
il.current = 2;
il.scroll_by(1); // wraps to 0
assert_eq!(il.current, 0);
il.scroll_by(-1); // wraps to 2
assert_eq!(il.current, 2);
}
#[test]
fn scroll_by_rows_moves_cursor() {
let mut il = list(10);
il.scroll_by_rows(3);
assert_eq!(il.current, 3);
il.scroll_by_rows(-2);
assert_eq!(il.current, 1);
// Zero is a no-op.
il.scroll_by_rows(0);
assert_eq!(il.current, 1);
}
#[test]
fn select_next_previous() {
let mut il = list(4);
il.select_next();
assert_eq!(il.current, 1);
il.select_previous();
assert_eq!(il.current, 0);
}
#[test]
fn jump_to_first_and_last() {
let mut il = list(6);
il.jump_to_last();
assert_eq!(il.current, 5);
il.jump_to_first();
assert_eq!(il.current, 0);
}
#[test]
fn item_at_visual_row_top_to_bottom() {
let mut il = list(5);
il.direction = ListDirection::TopToBottom;
assert_eq!(il.item_at_visual_row(0), Some(0));
assert_eq!(il.item_at_visual_row(2), Some(2));
// Row beyond available height.
assert_eq!(il.item_at_visual_row(100), None);
}
#[test]
fn item_at_visual_row_bottom_to_top() {
let mut il = list(10);
il.direction = ListDirection::BottomToTop;
// Bottom row (height-1) is the first item.
assert_eq!(il.item_at_visual_row(il.height as usize - 1), Some(0));
}
#[test]
fn toggle_and_toggle_all() {
let mut il = list(3);
il.multi_select = true;
il.toggle(); // toggle current (0)
assert_eq!(il.selection.len(), 1);
il.toggle(); // untoggle
assert_eq!(il.selection.len(), 0);
il.toggle_all();
assert_eq!(il.selection.len(), 3);
}
#[test]
fn select_and_select_all_and_clear() {
let mut il = list(3);
il.select();
assert_eq!(il.selection.len(), 1);
il.select_all();
assert_eq!(il.selection.len(), 3);
il.clear_selection();
assert!(il.selection.is_empty());
}
#[test]
fn clear_resets_state() {
let mut il = list(3);
il.current = 2;
il.select_all();
il.clear();
assert!(il.items.is_empty());
assert!(il.selection.is_empty());
assert_eq!(il.current, 0);
}
#[test]
fn count_zero_when_showing_stale() {
let mut il = list(3);
assert_eq!(il.count(), 3);
il.showing_stale_items = true;
assert_eq!(il.count(), 0);
}
#[test]
fn selected_returns_current_item() {
let il = list(3);
let sel = il.selected().expect("an item is selected");
assert_eq!(sel.text(), "item0");
}
#[test]
fn select_row_adds_specific_item() {
let mut il = list(4);
il.select_row(2);
assert_eq!(il.selection.len(), 1);
let only = il.selection.iter().next().unwrap();
assert_eq!(only.text(), "item2");
}
#[test]
fn processed_items_default_replaces() {
let pi = ProcessedItems::default();
assert!(pi.items.is_empty());
assert!(matches!(pi.merge, MergeStrategy::Replace));
}
#[test]
fn toggle_at_on_empty_list_is_noop() {
let mut il = ItemList::default();
// No items → early return without panicking.
il.toggle_at(0);
assert!(il.selection.is_empty());
}
#[test]
fn item_at_visual_row_out_of_range_is_none() {
let il = list(3);
// A row far beyond the items maps to nothing.
assert!(il.item_at_visual_row(999).is_none());
}
#[test]
fn append_clears_stale_flag() {
let mut il = list(2);
il.showing_stale_items = true;
let mut more = vec![matched("extra", 2)];
il.append(&mut more);
assert!(!il.showing_stale_items);
assert_eq!(il.items.len(), 3);
}
fn render_list(il: &mut ItemList, w: u16, h: u16) {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
il.render(area, &mut buf);
}
fn set_processed(il: &ItemList, items: Vec<MatchedItem>, merge: MergeStrategy) {
*il.processed_items.lock() = Some(ProcessedItems { items, merge });
}
#[test]
fn render_applies_replace_strategy() {
let mut il = list(2);
set_processed(&il, vec![matched("new", 0)], MergeStrategy::Replace);
render_list(&mut il, 20, 5);
// Replace swaps the whole list.
assert_eq!(il.items.len(), 1);
assert_eq!(il.items[0].text(), "new");
}
#[test]
fn render_applies_append_strategy() {
let mut il = list(2);
set_processed(&il, vec![matched("extra", 2)], MergeStrategy::Append);
render_list(&mut il, 20, 5);
// Append grows the existing list.
assert_eq!(il.items.len(), 3);
}
#[test]
fn render_applies_sorted_merge_strategy() {
let mut il = ItemList::default();
il.height = 10;
let mut base = vec![matched("a", 0)];
il.append(&mut base);
set_processed(&il, vec![matched("b", 1)], MergeStrategy::SortedMerge);
render_list(&mut il, 20, 5);
assert_eq!(il.items.len(), 2);
}
#[test]
fn render_empty_list_does_not_panic() {
let mut il = ItemList::default();
il.height = 5;
render_list(&mut il, 20, 5);
assert!(il.items.is_empty());
}
#[test]
fn render_scrolled_list_shows_lower_items() {
let mut il = list(30);
il.current = 20;
// Rendering a tall list with the cursor far down exercises the scroll path.
render_list(&mut il, 20, 6);
assert!(il.current < il.items.len());
}

View file

@ -584,220 +584,5 @@ impl<'a> ItemRenderer<'a> {
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use std::sync::Arc;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{List, Widget};
use super::*;
use crate::item::RankBuilder;
use crate::{Rank, SkimItem};
fn renderer(theme: &ColorTheme) -> ItemRenderer<'_> {
ItemRenderer {
theme,
selector_icon: ">",
multi_select_icon: "*",
ellipsis: "..",
container_width: 6,
wrap: false,
multiline: None,
show_score: false,
show_index: false,
multi_select: false,
tabstop: 4,
no_hscroll: false,
keep_right: false,
manual_hscroll: 0,
skip_to_pattern: None,
reverse_sub_lines: false,
highlight_line: false,
}
}
fn matched_item(text: &str, matched_range: Option<MatchRange>) -> MatchedItem {
MatchedItem::new(
Arc::new(text.to_owned()) as Arc<dyn SkimItem>,
Rank::default(),
matched_range,
&RankBuilder::default(),
)
}
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|span| span.content.as_ref()).collect()
}
fn spans_text(spans: &[Span<'_>]) -> String {
spans.iter().map(|span| span.content.as_ref()).collect()
}
fn rendered_row_text(item: ListItem<'static>, width: u16) -> String {
let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
List::new(vec![item]).render(buf.area, &mut buf);
(0..width)
.map(|x| buf.cell((x, 0)).expect("cell should be inside buffer").symbol())
.collect::<String>()
}
struct DisplayLongerThanText;
impl SkimItem for DisplayLongerThanText {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed("ab")
}
fn display(&self, _context: DisplayContext) -> Line<'_> {
Line::from("abcdefghi")
}
}
#[test]
fn split_sub_lines_uses_configured_separator() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
assert_eq!(renderer.split_sub_lines("alpha|beta"), vec!["alpha|beta"]);
renderer.multiline = Some("|");
assert_eq!(
renderer.split_sub_lines("alpha|beta|gamma"),
vec!["alpha", "beta", "gamma"]
);
}
#[test]
fn matched_range_as_char_range_normalizes_supported_ranges() {
assert_eq!(
ItemRenderer::matched_range("aébc", Some(&MatchRange::ByteRange(1, 3))),
(1, 2)
);
assert_eq!(
ItemRenderer::matched_range("abcdef", Some(&MatchRange::Chars(vec![1, 3, 4]))),
(1, 5)
);
assert_eq!(
ItemRenderer::matched_range("abcdef", Some(&MatchRange::Chars(vec![]))),
(0, 0)
);
assert_eq!(
ItemRenderer::matched_range("abcdef", Some(&MatchRange::CharRange(2, 4))),
(2, 4)
);
assert_eq!(ItemRenderer::matched_range("abcdef", None), (0, 0));
}
#[test]
fn display_matches_preserves_original_match_kind() {
assert!(matches!(
ItemRenderer::display_matches(Some(&MatchRange::ByteRange(1, 3))),
crate::Matches::ByteRange(1, 3)
));
assert!(matches!(
ItemRenderer::display_matches(Some(&MatchRange::CharRange(2, 4))),
crate::Matches::CharRange(2, 4)
));
assert!(matches!(
ItemRenderer::display_matches(Some(&MatchRange::Chars(vec![0, 2]))),
crate::Matches::CharIndices(indices) if indices == vec![0, 2]
));
assert!(matches!(ItemRenderer::display_matches(None), crate::Matches::None));
}
#[test]
fn prefix_spans_uses_state_for_icons_and_first_line_fields() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.multi_select = true;
renderer.show_score = true;
renderer.show_index = true;
let mut item = matched_item("alpha", None);
item.rank.score = 42;
item.rank.index = 7;
let current_selected = SubLineState {
is_current: true,
is_selected: true,
is_first: true,
is_first_sub_line: true,
needs_ellipsis: false,
};
let continuation = SubLineState {
is_current: true,
is_selected: true,
is_first: false,
is_first_sub_line: false,
needs_ellipsis: false,
};
assert_eq!(
spans_text(&renderer.prefix_spans(&item, &current_selected)),
">*[42] [7] "
);
assert_eq!(spans_text(&renderer.prefix_spans(&item, &continuation)), " ");
}
#[test]
fn trim_with_ellipsis_reserves_space_for_marker() {
let theme = ColorTheme::default();
let renderer = renderer(&theme);
let line = Line::from(vec![
Span::styled("abc", Style::default()),
Span::styled("def", Style::default()),
]);
let trimmed = renderer.trim_with_ellipsis(line, false);
assert_eq!(spans_text(&trimmed), "abcd..");
}
#[test]
fn continuation_sub_line_content_applies_hscroll() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.manual_hscroll = 2;
let line = renderer.continuation_sub_line_content("abcdefgh", false);
assert_eq!(line_text(&line), "..cdef");
}
#[test]
fn first_sub_line_content_keeps_display_longer_than_text() {
let theme = ColorTheme::default();
let renderer = renderer(&theme);
let item = MatchedItem::new(
Arc::new(DisplayLongerThanText) as Arc<dyn SkimItem>,
Rank::default(),
None,
&RankBuilder::default(),
);
let line = renderer.first_sub_line_content(&item, "ab", false, 0, 0);
assert_eq!(line_text(&line), "abcd..");
}
#[test]
fn render_item_with_multiline_skip_starts_at_skipped_sub_line() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.multiline = Some("|");
renderer.container_width = 20;
let item = matched_item("first|second|third", None);
let mut out = Vec::new();
let added = renderer.render_item(&item, false, false, 1, 10, 0, &mut out);
assert_eq!(added, 2);
assert_eq!(rendered_row_text(out.remove(0), 8), " second");
assert_eq!(rendered_row_text(out.remove(0), 8), " third ");
assert!(out.is_empty());
}
}
#[path = "item_renderer_tests.rs"]
mod tests;

View file

@ -0,0 +1,277 @@
use std::borrow::Cow;
use std::sync::Arc;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{List, Widget};
use super::*;
use crate::item::RankBuilder;
use crate::{Rank, SkimItem};
fn renderer(theme: &ColorTheme) -> ItemRenderer<'_> {
ItemRenderer {
theme,
selector_icon: ">",
multi_select_icon: "*",
ellipsis: "..",
container_width: 6,
wrap: false,
multiline: None,
show_score: false,
show_index: false,
multi_select: false,
tabstop: 4,
no_hscroll: false,
keep_right: false,
manual_hscroll: 0,
skip_to_pattern: None,
reverse_sub_lines: false,
highlight_line: false,
}
}
fn matched_item(text: &str, matched_range: Option<MatchRange>) -> MatchedItem {
MatchedItem::new(
Arc::new(text.to_owned()) as Arc<dyn SkimItem>,
Rank::default(),
matched_range,
&RankBuilder::default(),
)
}
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|span| span.content.as_ref()).collect()
}
fn spans_text(spans: &[Span<'_>]) -> String {
spans.iter().map(|span| span.content.as_ref()).collect()
}
fn rendered_row_text(item: ListItem<'static>, width: u16) -> String {
let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
List::new(vec![item]).render(buf.area, &mut buf);
(0..width)
.map(|x| buf.cell((x, 0)).expect("cell should be inside buffer").symbol())
.collect::<String>()
}
struct DisplayLongerThanText;
impl SkimItem for DisplayLongerThanText {
fn text(&self) -> Cow<'_, str> {
Cow::Borrowed("ab")
}
fn display(&self, _context: DisplayContext) -> Line<'_> {
Line::from("abcdefghi")
}
}
#[test]
fn split_sub_lines_uses_configured_separator() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
assert_eq!(renderer.split_sub_lines("alpha|beta"), vec!["alpha|beta"]);
renderer.multiline = Some("|");
assert_eq!(
renderer.split_sub_lines("alpha|beta|gamma"),
vec!["alpha", "beta", "gamma"]
);
}
#[test]
fn matched_range_as_char_range_normalizes_supported_ranges() {
assert_eq!(
ItemRenderer::matched_range("aébc", Some(&MatchRange::ByteRange(1, 3))),
(1, 2)
);
assert_eq!(
ItemRenderer::matched_range("abcdef", Some(&MatchRange::Chars(vec![1, 3, 4]))),
(1, 5)
);
assert_eq!(
ItemRenderer::matched_range("abcdef", Some(&MatchRange::Chars(vec![]))),
(0, 0)
);
assert_eq!(
ItemRenderer::matched_range("abcdef", Some(&MatchRange::CharRange(2, 4))),
(2, 4)
);
assert_eq!(ItemRenderer::matched_range("abcdef", None), (0, 0));
}
#[test]
fn display_matches_preserves_original_match_kind() {
assert!(matches!(
ItemRenderer::display_matches(Some(&MatchRange::ByteRange(1, 3))),
crate::Matches::ByteRange(1, 3)
));
assert!(matches!(
ItemRenderer::display_matches(Some(&MatchRange::CharRange(2, 4))),
crate::Matches::CharRange(2, 4)
));
assert!(matches!(
ItemRenderer::display_matches(Some(&MatchRange::Chars(vec![0, 2]))),
crate::Matches::CharIndices(indices) if indices == vec![0, 2]
));
assert!(matches!(ItemRenderer::display_matches(None), crate::Matches::None));
}
#[test]
fn prefix_spans_uses_state_for_icons_and_first_line_fields() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.multi_select = true;
renderer.show_score = true;
renderer.show_index = true;
let mut item = matched_item("alpha", None);
item.rank.score = 42;
item.rank.index = 7;
let current_selected = SubLineState {
is_current: true,
is_selected: true,
is_first: true,
is_first_sub_line: true,
needs_ellipsis: false,
};
let continuation = SubLineState {
is_current: true,
is_selected: true,
is_first: false,
is_first_sub_line: false,
needs_ellipsis: false,
};
assert_eq!(
spans_text(&renderer.prefix_spans(&item, &current_selected)),
">*[42] [7] "
);
assert_eq!(spans_text(&renderer.prefix_spans(&item, &continuation)), " ");
}
#[test]
fn prefix_spans_with_highlight_line_resets_prefix_background() {
// With --highlight-line, the current row's line-level background fills the
// whole row; the selector/marker prefix columns must reset their bg so they
// are not painted with the cursor/selected background.
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.highlight_line = true;
renderer.multi_select = true;
let item = matched_item("alpha", None);
let current = SubLineState {
is_current: true,
is_selected: true,
is_first: true,
is_first_sub_line: true,
needs_ellipsis: false,
};
// The icon/marker text is unchanged; only the background style differs.
let spans = renderer.prefix_spans(&item, &current);
assert_eq!(spans_text(&spans), ">*");
// The selector prefix uses the cursor style with its background reset.
assert_eq!(spans[0].style.bg, Some(ratatui::style::Color::Reset));
}
#[test]
fn trim_with_ellipsis_reserves_space_for_marker() {
let theme = ColorTheme::default();
let renderer = renderer(&theme);
let line = Line::from(vec![
Span::styled("abc", Style::default()),
Span::styled("def", Style::default()),
]);
let trimmed = renderer.trim_with_ellipsis(line, false);
assert_eq!(spans_text(&trimmed), "abcd..");
}
#[test]
fn continuation_sub_line_content_applies_hscroll() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.manual_hscroll = 2;
let line = renderer.continuation_sub_line_content("abcdefgh", false);
assert_eq!(line_text(&line), "..cdef");
}
#[test]
fn first_sub_line_content_keeps_display_longer_than_text() {
let theme = ColorTheme::default();
let renderer = renderer(&theme);
let item = MatchedItem::new(
Arc::new(DisplayLongerThanText) as Arc<dyn SkimItem>,
Rank::default(),
None,
&RankBuilder::default(),
);
let line = renderer.first_sub_line_content(&item, "ab", false, 0, 0);
assert_eq!(line_text(&line), "abcd..");
}
#[test]
fn calc_hscroll_container_narrower_than_ellipsis_returns_no_shift() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
// ellipsis ".." is width 2; a 1-cell container can't fit it.
renderer.container_width = 1;
let (shift, full_width, has_left, has_right) = renderer.calc_hscroll_for_width("hello world", 0, 0, 11);
assert_eq!(shift, 0);
assert_eq!(full_width, 11);
assert!(!has_left);
assert!(!has_right);
}
#[test]
fn calc_hscroll_keep_right_shifts_to_end() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.container_width = 6;
renderer.keep_right = true;
// No match (0,0) + keep_right → shift so the right edge is visible.
let (shift, _full, _l, _r) = renderer.calc_hscroll_for_width("hello world", 0, 0, 11);
// full_width(11) - available_width(6) = 5.
assert_eq!(shift, 5);
}
#[test]
fn calc_hscroll_match_wider_than_available_anchors_at_match_start() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.container_width = 4;
// Match spans chars 2..10 (width 8) which exceeds the 4-cell container,
// so the shift anchors at the match start width.
let (shift, _full, _l, _r) = renderer.calc_hscroll_for_width("abcdefghijkl", 2, 10, 12);
assert_eq!(shift, 2);
}
#[test]
fn render_item_with_multiline_skip_starts_at_skipped_sub_line() {
let theme = ColorTheme::default();
let mut renderer = renderer(&theme);
renderer.multiline = Some("|");
renderer.container_width = 20;
let item = matched_item("first|second|third", None);
let mut out = Vec::new();
let added = renderer.render_item(&item, false, false, 1, 10, 0, &mut out);
assert_eq!(added, 2);
assert_eq!(rendered_row_text(out.remove(0), 8), " second");
assert_eq!(rendered_row_text(out.remove(0), 8), " third ");
assert!(out.is_empty());
}

View file

@ -251,540 +251,5 @@ fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
use crate::tui::options::PreviewLayout;
// A convenient 80×24 terminal area.
fn area() -> Rect {
Rect::new(0, 0, 80, 24)
}
// ── helpers ────────────────────────────────────────────────────────────
/// Assert that `rect` covers the full `area` width.
fn assert_full_width(rect: Rect, area: Rect, label: &str) {
assert_eq!(rect.x, area.x, "{label}: x");
assert_eq!(rect.width, area.width, "{label}: width");
}
/// Assert that two rects are vertically adjacent (b starts immediately
/// below a).
fn assert_vertically_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.y + a.height, b.y, "{label}: b should start right after a");
}
/// Assert that two rects are horizontally adjacent (b starts immediately
/// to the right of a).
fn assert_horizontally_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.x + a.width, b.x, "{label}: b should start right after a");
}
// Compute layout with no reserved-item header lines (header_height = 0
// unless the test needs something different).
fn compute(options: &SkimOptions) -> AppLayout {
AppLayout::compute(area(), options, 0)
}
fn compute_with_header_height(options: &SkimOptions, header_height: u16) -> AppLayout {
AppLayout::compute(area(), options, header_height)
}
fn opts() -> SkimOptionsBuilder {
SkimOptionsBuilder::default()
}
// ── Default layout ─────────────────────────────────────────────────────
#[test]
fn default_no_preview_no_header() {
// Simple case: just list + input (status line takes 1 extra row).
let options = opts().build().unwrap();
let layout = compute(&options);
// input = 2 rows (1 prompt + 1 status)
assert_eq!(layout.input_area.height, 2);
// list fills the rest
assert_eq!(layout.list_area.height, 24 - 2);
assert!(layout.header_area.is_none());
assert!(layout.preview_area.is_none());
// list is above input
assert_full_width(layout.list_area, area(), "list");
assert_full_width(layout.input_area, area(), "input");
assert_vertically_adjacent(layout.list_area, layout.input_area, "list→input");
}
#[test]
fn default_inline_info_no_header() {
// InfoDisplay::Inline → input uses only 1 row.
let options = opts().inline_info(true).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 1);
assert_eq!(layout.list_area.height, 23);
}
#[test]
fn default_hidden_info_no_header() {
// InfoDisplay::Hidden → input also uses only 1 row (just the prompt).
let options = opts().no_info(true).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 1);
assert_eq!(layout.list_area.height, 23);
}
#[test]
fn default_with_header() {
let options = opts().header("My Header").build().unwrap();
// header_height = 1 line of static header text
let layout = compute_with_header_height(&options, 1);
// non-list = input(2) + header(1) = 3
assert_eq!(layout.list_area.height, 21);
assert_eq!(layout.header_area.unwrap().height, 1);
assert_eq!(layout.input_area.height, 2);
// Order: list | header | input (top to bottom)
let h = layout.header_area.unwrap();
assert_vertically_adjacent(layout.list_area, h, "list→header");
assert_vertically_adjacent(h, layout.input_area, "header→input");
}
#[test]
fn default_with_multiline_header() {
let options = opts().header("line1\nline2\nline3").build().unwrap();
let layout = compute_with_header_height(&options, 3);
// non-list = input(2) + header(3) = 5
assert_eq!(layout.list_area.height, 19);
assert_eq!(layout.header_area.unwrap().height, 3);
}
#[test]
fn default_with_header_lines() {
// header_lines > 0 also triggers show_header
let options = opts().header_lines(2usize).build().unwrap();
let layout = compute_with_header_height(&options, 2);
assert_eq!(layout.header_area.unwrap().height, 2);
assert_eq!(layout.list_area.height, 24 - 2 - 2);
}
// ── Template can be reused across different areas ───────────────────────
#[test]
fn template_apply_different_areas() {
// A single template should produce consistent proportional results
// regardless of which concrete area it is applied to.
let options = opts().build().unwrap();
let template = LayoutTemplate::from_options(&options, 0);
let small = template.apply(Rect::new(0, 0, 40, 12));
let large = template.apply(Rect::new(0, 0, 160, 48));
// Both should have input = 2 rows, list = height - 2.
assert_eq!(small.input_area.height, 2);
assert_eq!(small.list_area.height, 10);
assert_eq!(large.input_area.height, 2);
assert_eq!(large.list_area.height, 46);
}
// ── Reverse layout ─────────────────────────────────────────────────────
#[test]
fn reverse_no_preview_no_header() {
let options = opts().layout(TuiLayout::Reverse).build().unwrap();
let layout = compute(&options);
// input is at the top
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 2);
// list is below input
assert_eq!(layout.list_area.y, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertically_adjacent(layout.input_area, layout.list_area, "input→list");
}
#[test]
fn reverse_with_header() {
// In Reverse layout: input on top, header below input, then list.
let options = opts().layout(TuiLayout::Reverse).header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 2);
// non-list = input(2) + header(2) = 4
assert_eq!(layout.list_area.height, 20);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 2);
// Order: input | header | list
assert_vertically_adjacent(layout.input_area, h, "input→header");
assert_vertically_adjacent(h, layout.list_area, "header→list");
}
// ── ReverseList layout ─────────────────────────────────────────────────
#[test]
fn reverse_list_no_preview_no_header() {
// ReverseList: items top-to-bottom (same split as Default), input at
// the bottom.
let options = opts().layout(TuiLayout::ReverseList).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertically_adjacent(layout.list_area, layout.input_area, "list→input");
}
#[test]
fn reverse_list_with_header() {
let options = opts().layout(TuiLayout::ReverseList).header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 1);
// non-list = 2 + 1 = 3
assert_eq!(layout.list_area.height, 21);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 1);
// Order: list | header | input
assert_vertically_adjacent(layout.list_area, h, "list→header");
assert_vertically_adjacent(h, layout.input_area, "header→input");
}
// ── Preview: Left / Right ───────────────────────────────────────────────
#[test]
fn default_preview_right_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview is on the right: work_area = left half, preview = right half.
// 50% of 80 = 40.
assert_eq!(preview.width, 40);
assert_eq!(preview.x, 40);
// list and input share the same work-column width (40, not summed).
assert_eq!(layout.list_area.width, 40);
assert_eq!(layout.input_area.width, 40);
assert_horizontally_adjacent(layout.list_area, preview, "list→preview");
}
#[test]
fn default_preview_left_30_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("left:30%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview on the left: 30% of 80 = 24.
assert_eq!(preview.width, 24);
assert_eq!(preview.x, 0);
// work_area starts after preview
assert_eq!(layout.list_area.x, 24);
assert_horizontally_adjacent(preview, layout.list_area, "preview→list");
}
#[test]
fn default_preview_right_fixed_20() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:20"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.width, 20);
assert_eq!(layout.list_area.width, 60);
}
#[test]
fn reverse_preview_left() {
let options = opts()
.layout(TuiLayout::Reverse)
.preview("cat {}")
.preview_window(PreviewLayout::from("left:40%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// 40% of 80 = 32
assert_eq!(preview.width, 32);
// Input is at the top of work_area (Reverse)
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.x, 32);
}
// ── Preview: Up / Down ──────────────────────────────────────────────────
#[test]
fn default_preview_up_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("up:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview is carved from the full area first: 50% of 24 = 12 rows.
assert_eq!(preview.height, 12);
// Preview is at the top (y = 0).
assert_eq!(preview.y, 0);
// work_area starts right after the preview.
assert_eq!(layout.list_area.y, 12);
// work_area height = 24 - 12 = 12; input = 2; list = 10.
assert_eq!(layout.list_area.height, 10);
// input is at the bottom of the work area.
assert_eq!(layout.input_area.y, 22);
}
#[test]
fn default_preview_down_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("down:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview is carved from the full area: 50% of 24 = 12 rows at bottom.
assert_eq!(preview.height, 12);
// work_area is at the top (y = 0); preview starts after work_area.
assert_eq!(layout.list_area.y, 0);
assert_eq!(layout.list_area.height, 10);
// input is at the bottom of work_area (y = 10).
assert_eq!(layout.input_area.y, 10);
// Preview starts right after work_area (y = 12).
assert_eq!(preview.y, 12);
assert_vertically_adjacent(layout.input_area, preview, "input→preview");
}
#[test]
fn default_preview_up_fixed_8() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("up:8"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview = 8 rows at top; work_area = 24 - 8 = 16 rows; list = 14 rows.
assert_eq!(preview.height, 8);
assert_eq!(preview.y, 0);
assert_eq!(layout.list_area.height, 14);
assert_full_width(preview, area(), "preview");
}
// ── Preview hidden ─────────────────────────────────────────────────────
#[test]
fn preview_hidden_produces_no_preview_area() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%:hidden"))
.build()
.unwrap();
let layout = compute(&options);
assert!(layout.preview_area.is_none());
// Full width available to widgets.
assert_eq!(layout.list_area.width, 80);
}
#[test]
fn no_preview_command_produces_no_preview_area() {
// preview is None → no preview area even if preview_window is set.
let options = opts().preview_window(PreviewLayout::from("right:50%")).build().unwrap();
let layout = compute(&options);
assert!(layout.preview_area.is_none());
assert_eq!(layout.list_area.width, 80);
}
// ── With borders ───────────────────────────────────────────────────────
#[test]
fn default_with_borders_no_header() {
let options = opts().border(crate::tui::BorderType::Plain).build().unwrap();
let layout = compute(&options);
// input = 3 rows (1 content + 2 border)
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.height, 21);
assert!(layout.header_area.is_none());
}
#[test]
fn default_with_borders_and_header() {
let options = opts()
.border(crate::tui::BorderType::Plain)
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
// input = 3, header = 2+2 = 4
assert_eq!(layout.input_area.height, 3);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 4);
assert_eq!(layout.list_area.height, 24 - 3 - 4);
}
#[test]
fn reverse_with_borders() {
let options = opts()
.layout(TuiLayout::Reverse)
.border(crate::tui::BorderType::Plain)
.build()
.unwrap();
let layout = compute(&options);
// input at top (y = 0)
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.y, 3);
assert_eq!(layout.list_area.height, 21);
}
// ── Coverage / edge cases ──────────────────────────────────────────────
#[test]
fn all_areas_non_overlapping_default() {
// Ensure no area overlaps another for a complex configuration.
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:40%"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Preview and work area must not overlap horizontally.
assert!(
layout.list_area.x + layout.list_area.width <= preview.x || preview.x + preview.width <= layout.list_area.x,
"list and preview overlap"
);
// Vertical areas within work column must not overlap.
let rects = [layout.list_area, header, layout.input_area];
for i in 0..rects.len() {
for j in (i + 1)..rects.len() {
let a = rects[i];
let b = rects[j];
let vertically_disjoint = a.y + a.height <= b.y || b.y + b.height <= a.y;
assert!(vertically_disjoint, "rects[{i}] and rects[{j}] overlap vertically");
}
}
}
#[test]
fn all_areas_non_overlapping_reverse() {
let options = opts()
.layout(TuiLayout::Reverse)
.inline_info(true)
.border(crate::tui::BorderType::Plain)
.preview("cat {}")
.preview_window(PreviewLayout::from("left:25"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 1);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Horizontally disjoint: preview on left, everything else on right.
assert_eq!(preview.x, 0);
assert_eq!(preview.width, 25);
assert_eq!(layout.list_area.x, 25);
// Vertical ordering within work column (Reverse): input, header, list.
assert_vertically_adjacent(layout.input_area, header, "input→header");
assert_vertically_adjacent(header, layout.list_area, "header→list");
}
#[test]
fn total_height_is_area_height_default() {
// Sum of all vertical regions must equal area.height.
let options = opts().header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 3);
let total = layout.list_area.height + layout.header_area.unwrap().height + layout.input_area.height;
assert_eq!(total, area().height);
}
#[test]
fn total_width_is_area_width_with_right_preview() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%"))
.build()
.unwrap();
let layout = compute(&options);
let total = layout.list_area.width + layout.preview_area.unwrap().width;
assert_eq!(total, area().width);
}
#[test]
fn total_height_is_area_height_with_down_preview() {
// With a Down preview the vertical space must still sum to area height.
let options = opts()
.inline_info(true)
.preview("cat {}")
.preview_window(PreviewLayout::from("down:6"))
.build()
.unwrap();
let layout = compute(&options);
let total = layout.preview_area.unwrap().height + layout.list_area.height + layout.input_area.height;
assert_eq!(total, area().height);
}
#[test]
fn reverse_list_with_header_and_preview_right() {
let options = opts()
.layout(TuiLayout::ReverseList)
.preview("cat {}")
.preview_window(PreviewLayout::from("right:30%"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 1);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Preview on the right
assert!(preview.x > 0);
// ReverseList: same vertical order as Default (list | header | input)
assert_vertically_adjacent(layout.list_area, header, "list→header");
assert_vertically_adjacent(header, layout.input_area, "header→input");
// All in the same x-column (work area left of preview)
assert_eq!(layout.list_area.x, layout.input_area.x);
}
#[test]
fn very_small_area() {
// Ensure the layout does not panic on a tiny terminal.
let tiny = Rect::new(0, 0, 20, 5);
let options = opts().header("hdr").build().unwrap();
// Should not panic.
let layout = AppLayout::compute(tiny, &options, 1);
// input and header fit, list may have zero height but must exist.
assert_eq!(layout.list_area.width, 20);
}
}
#[path = "layout_tests.rs"]
mod tests;

535
src/tui/layout_tests.rs Normal file
View file

@ -0,0 +1,535 @@
use super::*;
use crate::options::SkimOptionsBuilder;
use crate::tui::options::PreviewLayout;
// A convenient 80×24 terminal area.
fn area() -> Rect {
Rect::new(0, 0, 80, 24)
}
// ── helpers ────────────────────────────────────────────────────────────
/// Assert that `rect` covers the full `area` width.
fn assert_full_width(rect: Rect, area: Rect, label: &str) {
assert_eq!(rect.x, area.x, "{label}: x");
assert_eq!(rect.width, area.width, "{label}: width");
}
/// Assert that two rects are vertically adjacent (b starts immediately
/// below a).
fn assert_vertically_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.y + a.height, b.y, "{label}: b should start right after a");
}
/// Assert that two rects are horizontally adjacent (b starts immediately
/// to the right of a).
fn assert_horizontally_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.x + a.width, b.x, "{label}: b should start right after a");
}
// Compute layout with no reserved-item header lines (header_height = 0
// unless the test needs something different).
fn compute(options: &SkimOptions) -> AppLayout {
AppLayout::compute(area(), options, 0)
}
fn compute_with_header_height(options: &SkimOptions, header_height: u16) -> AppLayout {
AppLayout::compute(area(), options, header_height)
}
fn opts() -> SkimOptionsBuilder {
SkimOptionsBuilder::default()
}
// ── Default layout ─────────────────────────────────────────────────────
#[test]
fn default_no_preview_no_header() {
// Simple case: just list + input (status line takes 1 extra row).
let options = opts().build().unwrap();
let layout = compute(&options);
// input = 2 rows (1 prompt + 1 status)
assert_eq!(layout.input_area.height, 2);
// list fills the rest
assert_eq!(layout.list_area.height, 24 - 2);
assert!(layout.header_area.is_none());
assert!(layout.preview_area.is_none());
// list is above input
assert_full_width(layout.list_area, area(), "list");
assert_full_width(layout.input_area, area(), "input");
assert_vertically_adjacent(layout.list_area, layout.input_area, "list→input");
}
#[test]
fn default_inline_info_no_header() {
// InfoDisplay::Inline → input uses only 1 row.
let options = opts().inline_info(true).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 1);
assert_eq!(layout.list_area.height, 23);
}
#[test]
fn default_hidden_info_no_header() {
// InfoDisplay::Hidden → input also uses only 1 row (just the prompt).
let options = opts().no_info(true).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 1);
assert_eq!(layout.list_area.height, 23);
}
#[test]
fn default_with_header() {
let options = opts().header("My Header").build().unwrap();
// header_height = 1 line of static header text
let layout = compute_with_header_height(&options, 1);
// non-list = input(2) + header(1) = 3
assert_eq!(layout.list_area.height, 21);
assert_eq!(layout.header_area.unwrap().height, 1);
assert_eq!(layout.input_area.height, 2);
// Order: list | header | input (top to bottom)
let h = layout.header_area.unwrap();
assert_vertically_adjacent(layout.list_area, h, "list→header");
assert_vertically_adjacent(h, layout.input_area, "header→input");
}
#[test]
fn default_with_multiline_header() {
let options = opts().header("line1\nline2\nline3").build().unwrap();
let layout = compute_with_header_height(&options, 3);
// non-list = input(2) + header(3) = 5
assert_eq!(layout.list_area.height, 19);
assert_eq!(layout.header_area.unwrap().height, 3);
}
#[test]
fn default_with_header_lines() {
// header_lines > 0 also triggers show_header
let options = opts().header_lines(2usize).build().unwrap();
let layout = compute_with_header_height(&options, 2);
assert_eq!(layout.header_area.unwrap().height, 2);
assert_eq!(layout.list_area.height, 24 - 2 - 2);
}
// ── Template can be reused across different areas ───────────────────────
#[test]
fn template_apply_different_areas() {
// A single template should produce consistent proportional results
// regardless of which concrete area it is applied to.
let options = opts().build().unwrap();
let template = LayoutTemplate::from_options(&options, 0);
let small = template.apply(Rect::new(0, 0, 40, 12));
let large = template.apply(Rect::new(0, 0, 160, 48));
// Both should have input = 2 rows, list = height - 2.
assert_eq!(small.input_area.height, 2);
assert_eq!(small.list_area.height, 10);
assert_eq!(large.input_area.height, 2);
assert_eq!(large.list_area.height, 46);
}
// ── Reverse layout ─────────────────────────────────────────────────────
#[test]
fn reverse_no_preview_no_header() {
let options = opts().layout(TuiLayout::Reverse).build().unwrap();
let layout = compute(&options);
// input is at the top
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 2);
// list is below input
assert_eq!(layout.list_area.y, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertically_adjacent(layout.input_area, layout.list_area, "input→list");
}
#[test]
fn reverse_with_header() {
// In Reverse layout: input on top, header below input, then list.
let options = opts().layout(TuiLayout::Reverse).header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 2);
// non-list = input(2) + header(2) = 4
assert_eq!(layout.list_area.height, 20);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 2);
// Order: input | header | list
assert_vertically_adjacent(layout.input_area, h, "input→header");
assert_vertically_adjacent(h, layout.list_area, "header→list");
}
// ── ReverseList layout ─────────────────────────────────────────────────
#[test]
fn reverse_list_no_preview_no_header() {
// ReverseList: items top-to-bottom (same split as Default), input at
// the bottom.
let options = opts().layout(TuiLayout::ReverseList).build().unwrap();
let layout = compute(&options);
assert_eq!(layout.input_area.height, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertically_adjacent(layout.list_area, layout.input_area, "list→input");
}
#[test]
fn reverse_list_with_header() {
let options = opts().layout(TuiLayout::ReverseList).header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 1);
// non-list = 2 + 1 = 3
assert_eq!(layout.list_area.height, 21);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 1);
// Order: list | header | input
assert_vertically_adjacent(layout.list_area, h, "list→header");
assert_vertically_adjacent(h, layout.input_area, "header→input");
}
// ── Preview: Left / Right ───────────────────────────────────────────────
#[test]
fn default_preview_right_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview is on the right: work_area = left half, preview = right half.
// 50% of 80 = 40.
assert_eq!(preview.width, 40);
assert_eq!(preview.x, 40);
// list and input share the same work-column width (40, not summed).
assert_eq!(layout.list_area.width, 40);
assert_eq!(layout.input_area.width, 40);
assert_horizontally_adjacent(layout.list_area, preview, "list→preview");
}
#[test]
fn default_preview_left_30_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("left:30%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview on the left: 30% of 80 = 24.
assert_eq!(preview.width, 24);
assert_eq!(preview.x, 0);
// work_area starts after preview
assert_eq!(layout.list_area.x, 24);
assert_horizontally_adjacent(preview, layout.list_area, "preview→list");
}
#[test]
fn default_preview_right_fixed_20() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:20"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
assert_eq!(preview.width, 20);
assert_eq!(layout.list_area.width, 60);
}
#[test]
fn reverse_preview_left() {
let options = opts()
.layout(TuiLayout::Reverse)
.preview("cat {}")
.preview_window(PreviewLayout::from("left:40%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// 40% of 80 = 32
assert_eq!(preview.width, 32);
// Input is at the top of work_area (Reverse)
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.x, 32);
}
// ── Preview: Up / Down ──────────────────────────────────────────────────
#[test]
fn default_preview_up_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("up:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview is carved from the full area first: 50% of 24 = 12 rows.
assert_eq!(preview.height, 12);
// Preview is at the top (y = 0).
assert_eq!(preview.y, 0);
// work_area starts right after the preview.
assert_eq!(layout.list_area.y, 12);
// work_area height = 24 - 12 = 12; input = 2; list = 10.
assert_eq!(layout.list_area.height, 10);
// input is at the bottom of the work area.
assert_eq!(layout.input_area.y, 22);
}
#[test]
fn default_preview_down_50_percent() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("down:50%"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview is carved from the full area: 50% of 24 = 12 rows at bottom.
assert_eq!(preview.height, 12);
// work_area is at the top (y = 0); preview starts after work_area.
assert_eq!(layout.list_area.y, 0);
assert_eq!(layout.list_area.height, 10);
// input is at the bottom of work_area (y = 10).
assert_eq!(layout.input_area.y, 10);
// Preview starts right after work_area (y = 12).
assert_eq!(preview.y, 12);
assert_vertically_adjacent(layout.input_area, preview, "input→preview");
}
#[test]
fn default_preview_up_fixed_8() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("up:8"))
.build()
.unwrap();
let layout = compute(&options);
let preview = layout.preview_area.unwrap();
// Preview = 8 rows at top; work_area = 24 - 8 = 16 rows; list = 14 rows.
assert_eq!(preview.height, 8);
assert_eq!(preview.y, 0);
assert_eq!(layout.list_area.height, 14);
assert_full_width(preview, area(), "preview");
}
// ── Preview hidden ─────────────────────────────────────────────────────
#[test]
fn preview_hidden_produces_no_preview_area() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%:hidden"))
.build()
.unwrap();
let layout = compute(&options);
assert!(layout.preview_area.is_none());
// Full width available to widgets.
assert_eq!(layout.list_area.width, 80);
}
#[test]
fn no_preview_command_produces_no_preview_area() {
// preview is None → no preview area even if preview_window is set.
let options = opts().preview_window(PreviewLayout::from("right:50%")).build().unwrap();
let layout = compute(&options);
assert!(layout.preview_area.is_none());
assert_eq!(layout.list_area.width, 80);
}
// ── With borders ───────────────────────────────────────────────────────
#[test]
fn default_with_borders_no_header() {
let options = opts().border(crate::tui::BorderType::Plain).build().unwrap();
let layout = compute(&options);
// input = 3 rows (1 content + 2 border)
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.height, 21);
assert!(layout.header_area.is_none());
}
#[test]
fn default_with_borders_and_header() {
let options = opts()
.border(crate::tui::BorderType::Plain)
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
// input = 3, header = 2+2 = 4
assert_eq!(layout.input_area.height, 3);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 4);
assert_eq!(layout.list_area.height, 24 - 3 - 4);
}
#[test]
fn reverse_with_borders() {
let options = opts()
.layout(TuiLayout::Reverse)
.border(crate::tui::BorderType::Plain)
.build()
.unwrap();
let layout = compute(&options);
// input at top (y = 0)
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.y, 3);
assert_eq!(layout.list_area.height, 21);
}
// ── Coverage / edge cases ──────────────────────────────────────────────
#[test]
fn all_areas_non_overlapping_default() {
// Ensure no area overlaps another for a complex configuration.
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:40%"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Preview and work area must not overlap horizontally.
assert!(
layout.list_area.x + layout.list_area.width <= preview.x || preview.x + preview.width <= layout.list_area.x,
"list and preview overlap"
);
// Vertical areas within work column must not overlap.
let rects = [layout.list_area, header, layout.input_area];
for i in 0..rects.len() {
for j in (i + 1)..rects.len() {
let a = rects[i];
let b = rects[j];
let vertically_disjoint = a.y + a.height <= b.y || b.y + b.height <= a.y;
assert!(vertically_disjoint, "rects[{i}] and rects[{j}] overlap vertically");
}
}
}
#[test]
fn all_areas_non_overlapping_reverse() {
let options = opts()
.layout(TuiLayout::Reverse)
.inline_info(true)
.border(crate::tui::BorderType::Plain)
.preview("cat {}")
.preview_window(PreviewLayout::from("left:25"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 1);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Horizontally disjoint: preview on left, everything else on right.
assert_eq!(preview.x, 0);
assert_eq!(preview.width, 25);
assert_eq!(layout.list_area.x, 25);
// Vertical ordering within work column (Reverse): input, header, list.
assert_vertically_adjacent(layout.input_area, header, "input→header");
assert_vertically_adjacent(header, layout.list_area, "header→list");
}
#[test]
fn total_height_is_area_height_default() {
// Sum of all vertical regions must equal area.height.
let options = opts().header("hdr").build().unwrap();
let layout = compute_with_header_height(&options, 3);
let total = layout.list_area.height + layout.header_area.unwrap().height + layout.input_area.height;
assert_eq!(total, area().height);
}
#[test]
fn total_width_is_area_width_with_right_preview() {
let options = opts()
.preview("cat {}")
.preview_window(PreviewLayout::from("right:50%"))
.build()
.unwrap();
let layout = compute(&options);
let total = layout.list_area.width + layout.preview_area.unwrap().width;
assert_eq!(total, area().width);
}
#[test]
fn total_height_is_area_height_with_down_preview() {
// With a Down preview the vertical space must still sum to area height.
let options = opts()
.inline_info(true)
.preview("cat {}")
.preview_window(PreviewLayout::from("down:6"))
.build()
.unwrap();
let layout = compute(&options);
let total = layout.preview_area.unwrap().height + layout.list_area.height + layout.input_area.height;
assert_eq!(total, area().height);
}
#[test]
fn reverse_list_with_header_and_preview_right() {
let options = opts()
.layout(TuiLayout::ReverseList)
.preview("cat {}")
.preview_window(PreviewLayout::from("right:30%"))
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 1);
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Preview on the right
assert!(preview.x > 0);
// ReverseList: same vertical order as Default (list | header | input)
assert_vertically_adjacent(layout.list_area, header, "list→header");
assert_vertically_adjacent(header, layout.input_area, "header→input");
// All in the same x-column (work area left of preview)
assert_eq!(layout.list_area.x, layout.input_area.x);
}
#[test]
fn very_small_area() {
// Ensure the layout does not panic on a tiny terminal.
let tiny = Rect::new(0, 0, 20, 5);
let options = opts().header("hdr").build().unwrap();
// Should not panic.
let layout = AppLayout::compute(tiny, &options, 1);
// input and header fit, list may have zero height but must exist.
assert_eq!(layout.list_area.width, 20);
}

View file

@ -247,4 +247,39 @@ mod size_test {
assert_eq!(internal_error.kind(), &IntErrorKind::Empty);
assert_eq!(value, String::from("%"));
}
#[test]
fn default_is_full_percent() {
assert_eq!(Size::default(), Size::Percent(100));
}
#[test]
fn display_formats_each_variant() {
assert_eq!(Size::Percent(50).to_string(), "50%");
assert_eq!(Size::Fixed(20).to_string(), "20");
assert_eq!(Size::Neg(5).to_string(), "-5");
}
#[test]
fn direction_try_from_parses_each() {
assert_eq!(Direction::try_from("up"), Ok(Direction::Up));
assert_eq!(Direction::try_from("DOWN"), Ok(Direction::Down));
assert_eq!(Direction::try_from("Left"), Ok(Direction::Left));
assert_eq!(Direction::try_from("right"), Ok(Direction::Right));
assert!(Direction::try_from("sideways").is_err());
}
#[test]
fn border_type_none_and_some() {
assert!(BorderType::None.is_none());
assert!(BorderType::ForceOff.is_none());
assert!(!BorderType::Plain.is_none());
assert!(BorderType::Rounded.is_some());
assert_eq!(BorderType::None.into_ratatui(), None);
assert_eq!(
BorderType::Plain.into_ratatui(),
Some(ratatui::widgets::BorderType::Plain)
);
}
}

View file

@ -88,86 +88,5 @@ impl From<&str> for PreviewLayout {
// }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preview_layout_direction_only() {
let layout = PreviewLayout::from("left");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(50)); // default
assert!(!layout.hidden);
assert_eq!(layout.offset, None);
let layout = PreviewLayout::from("right");
assert_eq!(layout.direction, Direction::Right);
let layout = PreviewLayout::from("up");
assert_eq!(layout.direction, Direction::Up);
let layout = PreviewLayout::from("down");
assert_eq!(layout.direction, Direction::Down);
}
#[test]
fn test_preview_layout_with_size() {
let layout = PreviewLayout::from("left:30%");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(30));
assert!(!layout.hidden);
assert_eq!(layout.offset, None);
let layout = PreviewLayout::from("right:40");
assert_eq!(layout.direction, Direction::Right);
assert_eq!(layout.size, Size::Fixed(40));
}
#[test]
fn test_preview_layout_with_offset() {
let layout = PreviewLayout::from("left:+123");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.offset, Some("+123".to_string()));
let layout = PreviewLayout::from("left:+{2}");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.offset, Some("+{2}".to_string()));
let layout = PreviewLayout::from("left:+{2}-2");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.offset, Some("+{2}-2".to_string()));
}
#[test]
fn test_preview_layout_with_size_and_offset() {
let layout = PreviewLayout::from("left:50%:+{2}");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(50));
assert_eq!(layout.offset, Some("+{2}".to_string()));
let layout = PreviewLayout::from("right:40:+123");
assert_eq!(layout.direction, Direction::Right);
assert_eq!(layout.size, Size::Fixed(40));
assert_eq!(layout.offset, Some("+123".to_string()));
}
#[test]
fn test_preview_layout_with_hidden() {
let layout = PreviewLayout::from("left:hidden");
assert_eq!(layout.direction, Direction::Left);
assert!(layout.hidden);
let layout = PreviewLayout::from("right:50%:hidden");
assert_eq!(layout.direction, Direction::Right);
assert_eq!(layout.size, Size::Percent(50));
assert!(layout.hidden);
}
#[test]
fn test_preview_layout_complex() {
let layout = PreviewLayout::from("left:30%:+{2}-5:hidden");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(30));
assert_eq!(layout.offset, Some("+{2}-5".to_string()));
assert!(layout.hidden);
}
}
#[path = "options_tests.rs"]
mod tests;

106
src/tui/options_tests.rs Normal file
View file

@ -0,0 +1,106 @@
use super::*;
#[test]
fn test_preview_layout_direction_only() {
let layout = PreviewLayout::from("left");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(50)); // default
assert!(!layout.hidden);
assert_eq!(layout.offset, None);
let layout = PreviewLayout::from("right");
assert_eq!(layout.direction, Direction::Right);
let layout = PreviewLayout::from("up");
assert_eq!(layout.direction, Direction::Up);
let layout = PreviewLayout::from("down");
assert_eq!(layout.direction, Direction::Down);
}
#[test]
fn test_preview_layout_with_size() {
let layout = PreviewLayout::from("left:30%");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(30));
assert!(!layout.hidden);
assert_eq!(layout.offset, None);
let layout = PreviewLayout::from("right:40");
assert_eq!(layout.direction, Direction::Right);
assert_eq!(layout.size, Size::Fixed(40));
}
#[test]
fn test_preview_layout_with_offset() {
let layout = PreviewLayout::from("left:+123");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.offset, Some("+123".to_string()));
let layout = PreviewLayout::from("left:+{2}");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.offset, Some("+{2}".to_string()));
let layout = PreviewLayout::from("left:+{2}-2");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.offset, Some("+{2}-2".to_string()));
}
#[test]
fn test_preview_layout_with_size_and_offset() {
let layout = PreviewLayout::from("left:50%:+{2}");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(50));
assert_eq!(layout.offset, Some("+{2}".to_string()));
let layout = PreviewLayout::from("right:40:+123");
assert_eq!(layout.direction, Direction::Right);
assert_eq!(layout.size, Size::Fixed(40));
assert_eq!(layout.offset, Some("+123".to_string()));
}
#[test]
fn test_preview_layout_with_hidden() {
let layout = PreviewLayout::from("left:hidden");
assert_eq!(layout.direction, Direction::Left);
assert!(layout.hidden);
let layout = PreviewLayout::from("right:50%:hidden");
assert_eq!(layout.direction, Direction::Right);
assert_eq!(layout.size, Size::Percent(50));
assert!(layout.hidden);
}
#[test]
fn test_preview_layout_complex() {
let layout = PreviewLayout::from("left:30%:+{2}-5:hidden");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(30));
assert_eq!(layout.offset, Some("+{2}-5".to_string()));
assert!(layout.hidden);
}
#[test]
fn test_preview_layout_toggle_negations() {
// The explicit `no*` spellings clear each boolean flag.
let layout = PreviewLayout::from("left:nohidden:nowrap:nopty");
assert!(!layout.hidden);
assert!(!layout.wrap);
assert!(!layout.pty);
}
#[test]
fn test_preview_layout_wrap_and_pty_enabled() {
let layout = PreviewLayout::from("up:wrap:pty");
assert_eq!(layout.direction, Direction::Up);
assert!(layout.wrap);
assert!(layout.pty);
}
#[test]
fn test_preview_layout_skips_empty_parts() {
// Consecutive colons yield empty parts that are skipped.
let layout = PreviewLayout::from("left::50%");
assert_eq!(layout.direction, Direction::Left);
assert_eq!(layout.size, Size::Percent(50));
}

View file

@ -733,46 +733,5 @@ impl SkimWidget for Preview {
}
#[cfg(test)]
mod tests {
use image::{DynamicImage, RgbaImage};
use ratatui::layout::Size;
use ratatui_image::picker::Picker;
use super::Preview;
fn image(width: u32, height: u32) -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::new(width, height))
}
#[test]
fn image_protocol_constrains_by_width() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(400, 200), Size::new(20, 10))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(20, 5));
}
#[test]
fn image_protocol_constrains_by_height() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(200, 400), Size::new(20, 10))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(10, 10));
}
#[test]
fn image_protocol_keeps_at_least_one_cell() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(1000, 1), Size::new(1, 1))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(1, 1));
}
#[test]
fn image_protocol_uses_halfblocks_picker_when_none_is_provided() {
let protocol = Preview::image_protocol(None, image(400, 200), Size::new(20, 10))
.expect("fallback halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(20, 5));
}
}
#[path = "preview_tests.rs"]
mod tests;

171
src/tui/preview_tests.rs Normal file
View file

@ -0,0 +1,171 @@
use image::{DynamicImage, RgbaImage};
use ratatui::layout::Size;
use ratatui_image::picker::Picker;
use super::Preview;
fn image(width: u32, height: u32) -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::new(width, height))
}
#[test]
fn image_protocol_constrains_by_width() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(400, 200), Size::new(20, 10))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(20, 5));
}
#[test]
fn image_protocol_constrains_by_height() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(200, 400), Size::new(20, 10))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(10, 10));
}
#[test]
fn image_protocol_keeps_at_least_one_cell() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(1000, 1), Size::new(1, 1))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(1, 1));
}
#[test]
fn image_protocol_uses_halfblocks_picker_when_none_is_provided() {
let protocol = Preview::image_protocol(None, image(400, 200), Size::new(20, 10))
.expect("fallback halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(20, 5));
}
#[test]
fn content_loads_text_and_resets_scroll() {
let mut p = Preview::default();
p.scroll_x = 5;
p.scroll_y = 5;
p.content(b"line1\nline2\nline3\n").unwrap();
assert_eq!(p.total_lines, 3);
assert_eq!(p.scroll_x, 0);
assert_eq!(p.scroll_y, 0);
assert!(!p.is_loading());
}
#[test]
fn vertical_scroll_clamps_to_content() {
let mut p = Preview::default();
p.rows = 3;
p.content(b"a\nb\nc\nd\ne\nf\n").unwrap();
assert_eq!(p.total_lines, 6);
p.scroll_down(100);
// Cannot scroll past total_lines - (rows - 1) == 6 - 2 == 4.
assert_eq!(p.scroll_y, 4);
p.scroll_up(100);
assert_eq!(p.scroll_y, 0);
}
#[test]
fn scroll_down_without_known_total_lines() {
let mut p = Preview::default();
p.total_lines = 0;
p.scroll_down(5);
assert_eq!(p.scroll_y, 5);
}
#[test]
fn horizontal_scroll() {
let mut p = Preview::default();
p.scroll_right(4);
assert_eq!(p.scroll_x, 4);
p.scroll_left(1);
assert_eq!(p.scroll_x, 3);
p.scroll_left(100);
assert_eq!(p.scroll_x, 0);
}
#[test]
fn set_offset_is_one_indexed() {
let mut p = Preview::default();
p.set_offset(10);
assert_eq!(p.scroll_y, 9);
p.set_offset(0);
assert_eq!(p.scroll_y, 0);
}
#[test]
fn page_up_and_down() {
let mut p = Preview::default();
p.rows = 10;
p.content(b"x\n".repeat(50).as_slice()).unwrap();
p.page_down();
let after_down = p.scroll_y;
assert!(after_down > 0);
p.page_up();
assert!(p.scroll_y < after_down);
}
#[test]
fn mark_ready_clears_loading() {
let mut p = Preview::default();
p.mark_ready();
assert!(!p.is_loading());
}
#[test]
fn content_with_position_applies_offsets() {
use crate::PreviewPosition;
use crate::tui::Size as PreviewSize;
let mut p = Preview::default();
p.rows = 100;
p.cols = 100;
p.content(b"x\n".repeat(50).as_slice()).unwrap();
let position = PreviewPosition {
v_scroll: PreviewSize::Fixed(5),
v_offset: PreviewSize::Fixed(2),
h_scroll: PreviewSize::Fixed(3),
h_offset: PreviewSize::Fixed(1),
};
p.content_with_position(b"x\n".repeat(50).as_slice(), position).unwrap();
assert_eq!(p.scroll_y, 7);
assert_eq!(p.scroll_x, 4);
}
#[test]
fn filter_and_respond_strips_query_sequences() {
let mut writer: Box<dyn std::io::Write + Send> = Box::new(Vec::new());
// A device-attributes query embedded in normal text.
let data = b"abc\x1b[cdef";
let filtered = Preview::filter_and_respond_to_queries(data, &mut writer);
let text = String::from_utf8_lossy(&filtered);
// The CSI query is consumed; surrounding text is preserved.
assert!(text.contains("abc"));
assert!(text.contains("def"));
assert!(!text.contains('\x1b'));
}
#[test]
fn size_to_offset_resolves_each_variant() {
let mut p = Preview::default();
p.rows = 50;
p.cols = 80;
// Fixed maps straight through.
assert_eq!(p.size_to_offset(super::super::Size::Fixed(7), true), 7);
// Percent is relative to the matching dimension.
assert_eq!(p.size_to_offset(super::super::Size::Percent(50), true), 25);
assert_eq!(p.size_to_offset(super::super::Size::Percent(50), false), 40);
// Neg subtracts from the matching dimension.
assert_eq!(p.size_to_offset(super::super::Size::Neg(10), true), 40);
assert_eq!(p.size_to_offset(super::super::Size::Neg(10), false), 70);
}
#[test]
fn set_image_picker_sets_and_clears() {
let mut p = Preview::default();
assert!(p.image_picker.is_none());
p.set_image_picker(Some(Picker::halfblocks()));
assert!(p.image_picker.is_some());
p.set_image_picker(None);
assert!(p.image_picker.is_none());
}

View file

@ -81,3 +81,62 @@ impl From<&str> for Info {
Self { display, separator }
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn spinner_char_returns_first_frame_immediately() {
// No elapsed time → index 0.
assert_eq!(spinner_char(Instant::now()), SPINNERS_UNICODE[0]);
}
#[test]
fn info_display_is_inline() {
assert!(InfoDisplay::Inline.is_inline());
assert!(InfoDisplay::InlineRight.is_inline());
assert!(!InfoDisplay::Default.is_inline());
assert!(!InfoDisplay::Hidden.is_inline());
}
#[test]
fn info_from_display_sets_separator_only_when_inline() {
let inline = Info::from(InfoDisplay::Inline);
assert_eq!(inline.separator(), Some(DEFAULT_SEPARATOR));
let right = Info::from(InfoDisplay::InlineRight);
assert_eq!(right.separator(), Some(DEFAULT_SEPARATOR));
let default = Info::from(InfoDisplay::Default);
assert_eq!(default.separator(), None);
let hidden = Info::from(InfoDisplay::Hidden);
assert_eq!(hidden.separator(), None);
}
#[test]
fn info_from_str_parses_each_mode() {
assert_eq!(Info::from("default").display, InfoDisplay::Default);
assert_eq!(Info::from("inline").display, InfoDisplay::Inline);
assert_eq!(Info::from("inline-right").display, InfoDisplay::InlineRight);
assert_eq!(Info::from("hidden").display, InfoDisplay::Hidden);
}
#[test]
fn info_from_str_uses_custom_and_default_separator() {
// Inline with an explicit separator after the colon.
assert_eq!(Info::from("inline: | ").separator(), Some(" | "));
// Inline without a separator falls back to the default.
assert_eq!(Info::from("inline").separator(), Some(DEFAULT_SEPARATOR));
// Non-inline modes never carry a separator.
assert_eq!(Info::from("hidden").separator(), None);
}
#[test]
#[should_panic(expected = "Failed to parse")]
fn info_from_str_panics_on_unknown_mode() {
let _ = Info::from("bogus");
}
}

View file

@ -316,281 +316,5 @@ pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> {
}
#[cfg(test)]
mod tests {
use super::*;
use ansi_to_tui::IntoText as _;
use ratatui::style::{Color, Style};
#[test]
fn test_wrap_text_no_wrap_needed() {
// Text shorter than width should not be wrapped
let input = Text::from("short");
let result = wrap_text(input.clone(), 10);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.lines[0].spans[0].content, "short");
}
#[test]
fn test_wrap_text_exact_width() {
// Text exactly at width should not be wrapped
let input = Text::from("exact");
let result = wrap_text(input, 5);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.lines[0].spans[0].content, "exact");
}
#[test]
fn test_wrap_text_simple_wrap() {
// Text longer than width should wrap
let input = Text::from("hello world");
let result = wrap_text(input, 5);
assert!(result.lines.len() > 1);
assert_eq!(result.lines[0].spans[0].content, "hello");
assert_eq!(result.lines[1].spans[0].content, " worl");
assert_eq!(result.lines[2].spans[0].content, "d");
}
#[test]
fn test_wrap_text_preserves_style() {
// Create styled text
let style = Style::default().fg(Color::Red);
let span = Span::styled("hello world", style);
let input = Text::from(Line::from(vec![span]));
let result = wrap_text(input, 5);
// Verify style is preserved across all spans
for line in &result.lines {
for span in &line.spans {
assert_eq!(span.style.fg, Some(Color::Red));
}
}
}
#[test]
fn test_wrap_text_multiple_spans() {
// Create text with multiple spans
let span1 = Span::styled("hello", Style::default().fg(Color::Red));
let span2 = Span::styled(" world", Style::default().fg(Color::Blue));
let input = Text::from(Line::from(vec![span1, span2]));
let result = wrap_text(input, 5);
// Should wrap into multiple lines
assert!(result.lines.len() > 1);
// Verify content is preserved
let reconstructed: String = result
.lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect();
assert_eq!(reconstructed, "hello world");
}
#[test]
fn test_wrap_text_multiple_lines() {
// Create text with multiple input lines
let input = Text::from(vec![Line::from("first line"), Line::from("second line")]);
let result = wrap_text(input, 5);
// Each input line should be processed
assert!(result.lines.len() >= 2);
}
#[test]
fn test_wrap_text_unicode_characters() {
// Test with wide Unicode characters
let input = Text::from("こんにちは"); // Japanese characters (2 width each)
let result = wrap_text(input, 6);
// Should wrap correctly based on display width
assert!(result.lines.len() > 1);
}
#[test]
fn test_wrap_text_zero_width_characters() {
// Test with combining characters
let input = Text::from("a\u{0301}b"); // a with accent
let result = wrap_text(input, 10);
// Should handle zero-width characters
assert_eq!(result.lines.len(), 1);
}
#[test]
fn test_wrap_text_width_one() {
// Edge case: wrap at width 1
let input = Text::from("abc");
let result = wrap_text(input, 1);
// Each character should be on its own line
assert_eq!(result.lines.len(), 3);
assert_eq!(result.lines[0].spans[0].content, "a");
assert_eq!(result.lines[1].spans[0].content, "b");
assert_eq!(result.lines[2].spans[0].content, "c");
}
#[test]
fn test_wrap_text_empty_input() {
// Test with empty text
let input = Text::default();
let result = wrap_text(input, 10);
// Should return empty text
assert_eq!(result.lines.len(), 0);
}
#[test]
fn test_wrap_text_preserves_multiple_styles() {
// Create complex multi-styled text
let red_style = Style::default().fg(Color::Red);
let blue_style = Style::default().fg(Color::Blue);
let green_style = Style::default().fg(Color::Green);
let span1 = Span::styled("hello", red_style);
let span2 = Span::styled("world", blue_style);
let span3 = Span::styled("test", green_style);
let input = Text::from(Line::from(vec![span1, span2, span3]));
let result = wrap_text(input, 5);
// Collect all styles from result
let styles: Vec<_> = result
.lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.style.fg)
.collect();
// Should contain all original colors
assert!(styles.contains(&Some(Color::Red)));
assert!(styles.contains(&Some(Color::Blue)));
assert!(styles.contains(&Some(Color::Green)));
}
#[test]
fn test_clip_line_to_chars_basic() {
let line = Line::from("hello world");
let clipped = clip_line_to_chars(line, 5);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "hello");
}
#[test]
fn test_clip_line_to_chars_exact_length() {
let line = Line::from("hello");
let clipped = clip_line_to_chars(line, 5);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "hello");
}
#[test]
fn test_clip_line_to_chars_longer_than_input() {
let line = Line::from("hi");
let clipped = clip_line_to_chars(line, 100);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "hi");
}
#[test]
fn test_clip_line_to_chars_zero() {
let line = Line::from("hello");
let clipped = clip_line_to_chars(line, 0);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "");
}
#[test]
fn test_clip_line_to_chars_preserves_styles() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let line = Line::from(vec![Span::styled("abc", red), Span::styled("def", blue)]);
// Clip at the span boundary.
let clipped = clip_line_to_chars(line, 3);
assert_eq!(clipped.spans.len(), 1);
assert_eq!(clipped.spans[0].content.as_ref(), "abc");
assert_eq!(clipped.spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_clip_line_to_chars_splits_span() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let line = Line::from(vec![Span::styled("abcde", red), Span::styled("fghij", blue)]);
// Clip inside the first span.
let clipped = clip_line_to_chars(line, 3);
assert_eq!(clipped.spans.len(), 1);
assert_eq!(clipped.spans[0].content.as_ref(), "abc");
assert_eq!(clipped.spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_clip_line_to_chars_splits_across_spans() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let line = Line::from(vec![Span::styled("abc", red), Span::styled("def", blue)]);
// Clip into the second span.
let clipped = clip_line_to_chars(line, 5);
assert_eq!(clipped.spans.len(), 2);
assert_eq!(clipped.spans[0].content.as_ref(), "abc");
assert_eq!(clipped.spans[1].content.as_ref(), "de");
assert_eq!(clipped.spans[1].style.fg, Some(Color::Blue));
}
#[test]
fn test_clip_line_to_chars_unicode() {
// Each kanji is one char.
let line = Line::from("日本語テスト");
let clipped = clip_line_to_chars(line, 3);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "日本語");
}
#[test]
fn test_merge_styles() {
use ratatui::style::Color::*;
use ratatui::style::Modifier;
let input = "before \x1b[1;34mline1\x1b[0m nocol";
let styled = input.into_text().unwrap().lines[0].clone();
let red = Style::new().red();
let underline = Style::new().underlined();
assert_eq!(merge_styles(red, styled.spans[0].style).fg, Some(Red));
assert_eq!(merge_styles(red, styled.spans[1].style).fg, Some(Blue));
assert_eq!(merge_styles(red, styled.spans[1].style).add_modifier, Modifier::BOLD);
assert_eq!(merge_styles(red, styled.spans[2].style).fg, Some(Red));
assert_eq!(
merge_styles(underline, styled.spans[0].style).add_modifier & Modifier::UNDERLINED,
Modifier::UNDERLINED
);
assert_eq!(
merge_styles(underline, styled.spans[1].style).add_modifier & Modifier::UNDERLINED,
Modifier::UNDERLINED
);
assert_eq!(
merge_styles(underline, styled.spans[2].style).add_modifier & Modifier::UNDERLINED,
Modifier::UNDERLINED
);
}
#[test]
fn test_style_text() {
use ratatui::style::Color::*;
use ratatui::style::Modifier;
let input = "before \x1b[1;34mline1\x1b[0m nocol";
let mut styled = input.into_text().unwrap();
let red = Style::new().red();
style_text(&mut styled, red);
assert_eq!(styled.lines.len(), 1);
let line = styled.lines[0].clone();
assert_eq!(line.spans[0].style.fg, Some(Red));
assert_eq!(line.spans[1].style.fg, Some(Blue));
assert_eq!(line.spans[1].style.add_modifier, Modifier::BOLD);
assert_eq!(line.spans[2].style.fg, Some(Red));
}
}
#[path = "util_tests.rs"]
mod tests;

414
src/tui/util_tests.rs Normal file
View file

@ -0,0 +1,414 @@
use super::*;
use ansi_to_tui::IntoText as _;
use ratatui::style::{Color, Style};
#[test]
fn test_wrap_text_no_wrap_needed() {
// Text shorter than width should not be wrapped
let input = Text::from("short");
let result = wrap_text(input.clone(), 10);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.lines[0].spans[0].content, "short");
}
#[test]
fn test_wrap_text_exact_width() {
// Text exactly at width should not be wrapped
let input = Text::from("exact");
let result = wrap_text(input, 5);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.lines[0].spans[0].content, "exact");
}
#[test]
fn test_wrap_text_simple_wrap() {
// Text longer than width should wrap
let input = Text::from("hello world");
let result = wrap_text(input, 5);
assert!(result.lines.len() > 1);
assert_eq!(result.lines[0].spans[0].content, "hello");
assert_eq!(result.lines[1].spans[0].content, " worl");
assert_eq!(result.lines[2].spans[0].content, "d");
}
#[test]
fn test_wrap_text_preserves_style() {
// Create styled text
let style = Style::default().fg(Color::Red);
let span = Span::styled("hello world", style);
let input = Text::from(Line::from(vec![span]));
let result = wrap_text(input, 5);
// Verify style is preserved across all spans
for line in &result.lines {
for span in &line.spans {
assert_eq!(span.style.fg, Some(Color::Red));
}
}
}
#[test]
fn test_wrap_text_multiple_spans() {
// Create text with multiple spans
let span1 = Span::styled("hello", Style::default().fg(Color::Red));
let span2 = Span::styled(" world", Style::default().fg(Color::Blue));
let input = Text::from(Line::from(vec![span1, span2]));
let result = wrap_text(input, 5);
// Should wrap into multiple lines
assert!(result.lines.len() > 1);
// Verify content is preserved
let reconstructed: String = result
.lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect();
assert_eq!(reconstructed, "hello world");
}
#[test]
fn test_wrap_text_multiple_lines() {
// Create text with multiple input lines
let input = Text::from(vec![Line::from("first line"), Line::from("second line")]);
let result = wrap_text(input, 5);
// Each input line should be processed
assert!(result.lines.len() >= 2);
}
#[test]
fn test_wrap_text_unicode_characters() {
// Test with wide Unicode characters
let input = Text::from("こんにちは"); // Japanese characters (2 width each)
let result = wrap_text(input, 6);
// Should wrap correctly based on display width
assert!(result.lines.len() > 1);
}
#[test]
fn test_wrap_text_zero_width_characters() {
// Test with combining characters
let input = Text::from("a\u{0301}b"); // a with accent
let result = wrap_text(input, 10);
// Should handle zero-width characters
assert_eq!(result.lines.len(), 1);
}
#[test]
fn test_wrap_text_width_one() {
// Edge case: wrap at width 1
let input = Text::from("abc");
let result = wrap_text(input, 1);
// Each character should be on its own line
assert_eq!(result.lines.len(), 3);
assert_eq!(result.lines[0].spans[0].content, "a");
assert_eq!(result.lines[1].spans[0].content, "b");
assert_eq!(result.lines[2].spans[0].content, "c");
}
#[test]
fn test_wrap_text_empty_input() {
// Test with empty text
let input = Text::default();
let result = wrap_text(input, 10);
// Should return empty text
assert_eq!(result.lines.len(), 0);
}
#[test]
fn test_wrap_text_preserves_multiple_styles() {
// Create complex multi-styled text
let red_style = Style::default().fg(Color::Red);
let blue_style = Style::default().fg(Color::Blue);
let green_style = Style::default().fg(Color::Green);
let span1 = Span::styled("hello", red_style);
let span2 = Span::styled("world", blue_style);
let span3 = Span::styled("test", green_style);
let input = Text::from(Line::from(vec![span1, span2, span3]));
let result = wrap_text(input, 5);
// Collect all styles from result
let styles: Vec<_> = result
.lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.style.fg)
.collect();
// Should contain all original colors
assert!(styles.contains(&Some(Color::Red)));
assert!(styles.contains(&Some(Color::Blue)));
assert!(styles.contains(&Some(Color::Green)));
}
#[test]
fn test_clip_line_to_chars_basic() {
let line = Line::from("hello world");
let clipped = clip_line_to_chars(line, 5);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "hello");
}
#[test]
fn test_clip_line_to_chars_exact_length() {
let line = Line::from("hello");
let clipped = clip_line_to_chars(line, 5);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "hello");
}
#[test]
fn test_clip_line_to_chars_longer_than_input() {
let line = Line::from("hi");
let clipped = clip_line_to_chars(line, 100);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "hi");
}
#[test]
fn test_clip_line_to_chars_zero() {
let line = Line::from("hello");
let clipped = clip_line_to_chars(line, 0);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "");
}
#[test]
fn test_clip_line_to_chars_preserves_styles() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let line = Line::from(vec![Span::styled("abc", red), Span::styled("def", blue)]);
// Clip at the span boundary.
let clipped = clip_line_to_chars(line, 3);
assert_eq!(clipped.spans.len(), 1);
assert_eq!(clipped.spans[0].content.as_ref(), "abc");
assert_eq!(clipped.spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_clip_line_to_chars_splits_span() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let line = Line::from(vec![Span::styled("abcde", red), Span::styled("fghij", blue)]);
// Clip inside the first span.
let clipped = clip_line_to_chars(line, 3);
assert_eq!(clipped.spans.len(), 1);
assert_eq!(clipped.spans[0].content.as_ref(), "abc");
assert_eq!(clipped.spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_clip_line_to_chars_splits_across_spans() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let line = Line::from(vec![Span::styled("abc", red), Span::styled("def", blue)]);
// Clip into the second span.
let clipped = clip_line_to_chars(line, 5);
assert_eq!(clipped.spans.len(), 2);
assert_eq!(clipped.spans[0].content.as_ref(), "abc");
assert_eq!(clipped.spans[1].content.as_ref(), "de");
assert_eq!(clipped.spans[1].style.fg, Some(Color::Blue));
}
#[test]
fn test_clip_line_to_chars_unicode() {
// Each kanji is one char.
let line = Line::from("日本語テスト");
let clipped = clip_line_to_chars(line, 3);
let content: String = clipped.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "日本語");
}
#[test]
fn test_merge_styles() {
use ratatui::style::Color::*;
use ratatui::style::Modifier;
let input = "before \x1b[1;34mline1\x1b[0m nocol";
let styled = input.into_text().unwrap().lines[0].clone();
let red = Style::new().red();
let underline = Style::new().underlined();
assert_eq!(merge_styles(red, styled.spans[0].style).fg, Some(Red));
assert_eq!(merge_styles(red, styled.spans[1].style).fg, Some(Blue));
assert_eq!(merge_styles(red, styled.spans[1].style).add_modifier, Modifier::BOLD);
assert_eq!(merge_styles(red, styled.spans[2].style).fg, Some(Red));
assert_eq!(
merge_styles(underline, styled.spans[0].style).add_modifier & Modifier::UNDERLINED,
Modifier::UNDERLINED
);
assert_eq!(
merge_styles(underline, styled.spans[1].style).add_modifier & Modifier::UNDERLINED,
Modifier::UNDERLINED
);
assert_eq!(
merge_styles(underline, styled.spans[2].style).add_modifier & Modifier::UNDERLINED,
Modifier::UNDERLINED
);
}
#[test]
fn test_style_text() {
use ratatui::style::Color::*;
use ratatui::style::Modifier;
let input = "before \x1b[1;34mline1\x1b[0m nocol";
let mut styled = input.into_text().unwrap();
let red = Style::new().red();
style_text(&mut styled, red);
assert_eq!(styled.lines.len(), 1);
let line = styled.lines[0].clone();
assert_eq!(line.spans[0].style.fg, Some(Red));
assert_eq!(line.spans[1].style.fg, Some(Blue));
assert_eq!(line.spans[1].style.add_modifier, Modifier::BOLD);
assert_eq!(line.spans[2].style.fg, Some(Red));
}
#[test]
fn test_char_display_width() {
assert_eq!(char_display_width('a'), 1);
// Variation selector forces double width.
assert_eq!(char_display_width('\u{FE0F}'), 2);
// Wide CJK character.
assert_eq!(char_display_width('日'), 2);
}
#[test]
fn test_find_osc_end_bel_terminator() {
// ESC ] ... BEL (BEL at index 9, end is one past it)
let data = b"\x1b]0;title\x07rest";
assert_eq!(find_osc_end(data), Some(10));
}
#[test]
fn test_find_osc_end_st_terminator() {
// ESC ] ... ESC \ (ESC at 9, backslash at 10, end is one past it)
let data = b"\x1b]0;title\x1b\\rest";
assert_eq!(find_osc_end(data), Some(11));
}
#[test]
fn test_find_osc_end_unterminated() {
let data = b"\x1b]0;title";
assert_eq!(find_osc_end(data), None);
}
#[test]
fn test_find_csi_end() {
// ESC [ 6 n -> terminator 'n' at index 3
let data = b"\x1b[6n";
assert_eq!(find_csi_end(data), Some(4));
// Unterminated parameter bytes only.
assert_eq!(find_csi_end(b"\x1b[12;34"), None);
}
/// A `Send` writer that captures everything written for assertions.
#[derive(Clone)]
struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl SharedBuf {
fn new() -> Self {
Self(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
}
fn contents(&self) -> Vec<u8> {
self.0.lock().unwrap().clone()
}
}
impl std::io::Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn test_handle_osc_query_foreground() {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
handle_osc_query(b"\x1b]10;?\x07", &mut writer);
assert!(buf.contents().starts_with(b"\x1b]10;rgb:"));
}
#[test]
fn test_handle_osc_query_background() {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
handle_osc_query(b"\x1b]11;?\x07", &mut writer);
assert!(buf.contents().starts_with(b"\x1b]11;rgb:"));
}
#[test]
fn test_handle_osc_query_palette() {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
handle_osc_query(b"\x1b]4;1;?\x07", &mut writer);
let out = buf.contents();
assert!(out.starts_with(b"\x1b]4;1;rgb:"));
}
#[test]
fn test_handle_osc_query_ignores_non_query() {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
handle_osc_query(b"\x1b]0;just a title\x07", &mut writer);
assert!(buf.contents().is_empty());
}
#[test]
fn test_handle_csi_query_device_attributes() {
for (query, expected_prefix) in [
(&b"\x1b[c"[..], &b"\x1b[?1;2c"[..]),
(&b"\x1b[>c"[..], &b"\x1b[>0;0;0c"[..]),
(&b"\x1b[5n"[..], &b"\x1b[0n"[..]),
(&b"\x1b[6n"[..], &b"\x1b[1;1R"[..]),
] {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
assert!(handle_csi_query(query, &mut writer));
assert_eq!(buf.contents(), expected_prefix);
}
}
#[test]
fn test_handle_csi_query_extended_cursor_report() {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
assert!(handle_csi_query(b"\x1b[?6n", &mut writer));
assert_eq!(buf.contents(), b"\x1b[?1;1;1R");
}
#[test]
fn test_handle_csi_query_non_query_returns_false() {
let buf = SharedBuf::new();
let mut writer: Box<dyn std::io::Write + Send> = Box::new(buf.clone());
assert!(!handle_csi_query(b"\x1b[1;2H", &mut writer));
assert!(buf.contents().is_empty());
}
#[test]
fn test_style_span_and_line() {
use ratatui::style::Color;
let red = Style::default().fg(Color::Red);
let mut span = Span::raw("hi");
style_span(&mut span, red);
assert_eq!(span.style.fg, Some(Color::Red));
let mut line = Line::from(vec![Span::raw("a"), Span::raw("b")]);
style_line(&mut line, red);
assert!(line.spans.iter().all(|s| s.style.fg == Some(Color::Red)));
}

View file

@ -249,137 +249,5 @@ pub fn printf<'a>(
}
#[cfg(test)]
mod test {
use super::*;
use crate::Rank;
use crate::item::{MatchedItem, RankBuilder};
use regex::Regex;
use std::sync::Arc;
fn make_item(s: &'static str) -> MatchedItem {
MatchedItem::new(Arc::new(s), Rank::default(), None, &RankBuilder::default())
}
#[test]
fn test_unescape_delimiter() {
assert_eq!(unescape_delimiter(r"\x00"), "\0");
assert_eq!(unescape_delimiter(r"\t"), "\t");
assert_eq!(unescape_delimiter(r"\n"), "\n");
assert_eq!(unescape_delimiter(r"\r"), "\r");
assert_eq!(unescape_delimiter(r"\\"), "\\");
assert_eq!(unescape_delimiter(r"\x09"), "\t");
assert_eq!(unescape_delimiter(r"\x0a"), "\n");
assert_eq!(unescape_delimiter(r"foo\x00bar"), "foo\0bar");
assert_eq!(unescape_delimiter(r"[\t\n ]+"), "[\t\n ]+");
// Invalid escape sequences should be kept as-is
assert_eq!(unescape_delimiter(r"\xGG"), r"\xGG");
assert_eq!(unescape_delimiter(r"\x0"), r"\x0");
}
#[test]
fn test_regex_null_byte_matching() {
use regex::Regex;
// Test that Regex can match null bytes
let delimiter = unescape_delimiter(r"\x00");
let re = Regex::new(&delimiter).unwrap();
let text = "a\x00b\x00c";
let matches: Vec<_> = re.find_iter(text).collect();
assert_eq!(matches.len(), 2, "Should find 2 null byte delimiters");
assert_eq!(matches[0].start(), 1);
assert_eq!(matches[0].end(), 2);
assert_eq!(matches[1].start(), 3);
assert_eq!(matches[1].end(), 4);
}
#[test]
fn test_printf() {
let pattern = "[1] {} [2] {..2} [3] {2..} [4] {+} [5] {q} [6] {cq} [7] {+:, } [8] {+n:','}";
let items = [
make_item("item 1"),
make_item("item 2"),
make_item("item 3"),
make_item("item 4"),
];
let delimiter = Regex::new(" ").unwrap();
assert_eq!(
&printf(
pattern,
&delimiter,
"{}",
&items.iter(),
&Some(make_item("item 2")),
"query",
"cmd query",
true
),
if cfg!(unix) {
"[1] 'item 2' [2] 'item 2' [3] '2' [4] 'item 1' 'item 2' 'item 3' 'item 4' [5] 'query' [6] 'cmd query' [7] 'item 1, item 2, item 3, item 4' [8] '0','0','0','0'"
} else {
"[1] item 2 [2] item 2 [3] 2 [4] item 1 item 2 item 3 item 4 [5] query [6] cmd query [7] item 1, item 2, item 3, item 4 [8] 0','0','0','0"
}
);
}
#[test]
fn test_printf_plus() {
assert_eq!(
printf(
"{+}",
&Regex::new(" ").unwrap(),
"{}",
&[make_item("1"), make_item("2")].iter(),
&Some(make_item("1")),
"q",
"cq",
true
),
if cfg!(unix) { "'1' '2'" } else { "1 2" }
);
assert_eq!(
printf(
"{+}",
&Regex::new(" ").unwrap(),
"{}",
&[].iter(),
&Some(make_item("1")),
"q",
"cq",
true
),
if cfg!(unix) { "'1'" } else { "1" }
);
}
#[test]
fn test_printf_norec() {
assert_eq!(
printf(
"{}",
&Regex::new(" ").unwrap(),
"{}",
&[].iter(),
&Some(make_item("{..2}")),
"q",
"cq",
true
),
if cfg!(unix) { "'{..2}'" } else { "{..2}" }
);
}
#[test]
fn test_printf_replstr() {
assert_eq!(
printf(
"{} ##",
&Regex::new(" ").unwrap(),
"##",
&[make_item("1"), make_item("2")].iter(),
&Some(make_item("1")),
"q",
"cq",
true
),
if cfg!(unix) { "{} '1'" } else { "{} 1" }
);
}
}
#[path = "util_tests.rs"]
mod test;

278
src/util_tests.rs Normal file
View file

@ -0,0 +1,278 @@
use super::*;
use crate::Rank;
use crate::item::{MatchedItem, RankBuilder};
use regex::Regex;
use std::sync::Arc;
fn make_item(s: &'static str) -> MatchedItem {
MatchedItem::new(Arc::new(s), Rank::default(), None, &RankBuilder::default())
}
#[test]
fn test_unescape_delimiter() {
assert_eq!(unescape_delimiter(r"\x00"), "\0");
assert_eq!(unescape_delimiter(r"\t"), "\t");
assert_eq!(unescape_delimiter(r"\n"), "\n");
assert_eq!(unescape_delimiter(r"\r"), "\r");
assert_eq!(unescape_delimiter(r"\\"), "\\");
assert_eq!(unescape_delimiter(r"\x09"), "\t");
assert_eq!(unescape_delimiter(r"\x0a"), "\n");
assert_eq!(unescape_delimiter(r"foo\x00bar"), "foo\0bar");
assert_eq!(unescape_delimiter(r"[\t\n ]+"), "[\t\n ]+");
// Invalid escape sequences should be kept as-is
assert_eq!(unescape_delimiter(r"\xGG"), r"\xGG");
assert_eq!(unescape_delimiter(r"\x0"), r"\x0");
}
#[test]
fn test_regex_null_byte_matching() {
use regex::Regex;
// Test that Regex can match null bytes
let delimiter = unescape_delimiter(r"\x00");
let re = Regex::new(&delimiter).unwrap();
let text = "a\x00b\x00c";
let matches: Vec<_> = re.find_iter(text).collect();
assert_eq!(matches.len(), 2, "Should find 2 null byte delimiters");
assert_eq!(matches[0].start(), 1);
assert_eq!(matches[0].end(), 2);
assert_eq!(matches[1].start(), 3);
assert_eq!(matches[1].end(), 4);
}
#[test]
fn test_printf() {
let pattern = "[1] {} [2] {..2} [3] {2..} [4] {+} [5] {q} [6] {cq} [7] {+:, } [8] {+n:','}";
let items = [
make_item("item 1"),
make_item("item 2"),
make_item("item 3"),
make_item("item 4"),
];
let delimiter = Regex::new(" ").unwrap();
assert_eq!(
&printf(
pattern,
&delimiter,
"{}",
&items.iter(),
&Some(make_item("item 2")),
"query",
"cmd query",
true
),
if cfg!(unix) {
"[1] 'item 2' [2] 'item 2' [3] '2' [4] 'item 1' 'item 2' 'item 3' 'item 4' [5] 'query' [6] 'cmd query' [7] 'item 1, item 2, item 3, item 4' [8] '0','0','0','0'"
} else {
"[1] item 2 [2] item 2 [3] 2 [4] item 1 item 2 item 3 item 4 [5] query [6] cmd query [7] item 1, item 2, item 3, item 4 [8] 0','0','0','0"
}
);
}
#[test]
fn test_printf_plus() {
assert_eq!(
printf(
"{+}",
&Regex::new(" ").unwrap(),
"{}",
&[make_item("1"), make_item("2")].iter(),
&Some(make_item("1")),
"q",
"cq",
true
),
if cfg!(unix) { "'1' '2'" } else { "1 2" }
);
assert_eq!(
printf(
"{+}",
&Regex::new(" ").unwrap(),
"{}",
&[].iter(),
&Some(make_item("1")),
"q",
"cq",
true
),
if cfg!(unix) { "'1'" } else { "1" }
);
}
#[test]
fn test_printf_norec() {
assert_eq!(
printf(
"{}",
&Regex::new(" ").unwrap(),
"{}",
&[].iter(),
&Some(make_item("{..2}")),
"q",
"cq",
true
),
if cfg!(unix) { "'{..2}'" } else { "{..2}" }
);
}
#[test]
fn test_printf_replstr() {
assert_eq!(
printf(
"{} ##",
&Regex::new(" ").unwrap(),
"##",
&[make_item("1"), make_item("2")].iter(),
&Some(make_item("1")),
"q",
"cq",
true
),
if cfg!(unix) { "{} '1'" } else { "{} 1" }
);
}
/// `{n}` expands to the current item's rank index.
#[test]
fn test_printf_index() {
assert_eq!(
printf(
"idx={n}",
&Regex::new(" ").unwrap(),
"{}",
&[make_item("a")].iter(),
&Some(make_item("a")),
"q",
"cq",
false
),
"idx=0"
);
}
/// `{n}` with no current item leaves the placeholder verbatim.
#[test]
fn test_printf_index_no_current() {
assert_eq!(
printf(
"idx={n}",
&Regex::new(" ").unwrap(),
"{}",
&[].iter(),
&None,
"q",
"cq",
false
),
"idx={n}"
);
}
/// `{+n}` joins all selected indices; `{+n:,}` uses an explicit delimiter.
#[test]
fn test_printf_plus_index() {
let items = [make_item("a"), make_item("b")];
assert_eq!(
printf(
"{+n:,}",
&Regex::new(" ").unwrap(),
"{}",
&items.iter(),
&Some(make_item("a")),
"q",
"cq",
false
),
"0,0"
);
}
/// `{+n}` with no selection falls back to the current item's index.
#[test]
fn test_printf_plus_index_fallback_to_current() {
assert_eq!(
printf(
"{+n}",
&Regex::new(" ").unwrap(),
"{}",
&[].iter(),
&Some(make_item("x")),
"q",
"cq",
false
),
"0"
);
}
/// `{+FIELD}` expands a field range across every selected item.
#[test]
fn test_printf_plus_field_range() {
let items = [make_item("a b c"), make_item("d e f")];
assert_eq!(
printf(
"{+2:,}",
&Regex::new(" ").unwrap(),
"{}",
&items.iter(),
&Some(make_item("a b c")),
"q",
"cq",
false
),
"b,e"
);
}
/// An unparsable `{+FIELD}` range is left verbatim and logged.
#[test]
fn test_printf_plus_invalid_field() {
assert_eq!(
printf(
"{+zz}",
&Regex::new(" ").unwrap(),
"{}",
&[make_item("a b")].iter(),
&Some(make_item("a b")),
"q",
"cq",
false
),
"{+zz}"
);
}
/// An unparsable single-item `{FIELD}` range is left verbatim and logged.
#[test]
fn test_printf_invalid_field() {
assert_eq!(
printf(
"{zz}",
&Regex::new(" ").unwrap(),
"{}",
&[make_item("a b")].iter(),
&Some(make_item("a b")),
"q",
"cq",
false
),
"{zz}"
);
}
/// Null bytes in item text are escaped to the literal `\0` sequence.
#[test]
fn test_printf_escapes_null_byte() {
assert_eq!(
printf(
"{}",
&Regex::new(" ").unwrap(),
"{}",
&[make_item("a\0b")].iter(),
&Some(make_item("a\0b")),
"q",
"cq",
false
),
"a\\0b"
);
}

View file

@ -4,65 +4,46 @@
#[macro_use]
mod common;
#[cfg(unix)]
use common::tmux::Keys::*;
#[cfg(unix)]
sk_test!(test_ansi_flag_enabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &["--ansi", "--color", "current_match_bg:1,current_bg:2"], {
@capture[0] starts_with(">");
@lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain")));
@keys Key('d');
@capture[2] starts_with("> red");
@capture_colored[*] contains("mre\u{1b}");
@keys Enter;
@output[*] trim().eq("red");
// With --ansi the colored input is interpreted: the items render with their
// ANSI colors and the matched query characters are highlighted. `@snap_color`
// captures the per-cell styling so this actually verifies color, while
// `--color current_match_bg:1,current_bg:2` exercises the themed selection.
insta_test!(test_ansi_flag_enabled, @bytes b"plain\n\x1b[31mred\x1b[0m\n\x1b[32mgreen\x1b[0m\n", &["--ansi", "--color", "current_match_bg:1,current_bg:2"], {
@type "d";
@snap;
@snap_color;
});
#[cfg(unix)]
sk_test!(test_ansi_flag_disabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &[], {
@capture[0] starts_with(">");
@capture[*] contains("plain");
@keys Str("red");
@capture[2] eq("> ?[31mred?[0m");
@keys Enter;
// Without --ansi, the escape sequences are not interpreted: they are matched
// and displayed as literal text, and the items carry no color. `@snap_color`
// asserts the absence of ANSI-derived styling on the item rows.
insta_test!(test_ansi_flag_disabled, @bytes b"plain\n\x1b[31mred\x1b[0m\n\x1b[32mgreen\x1b[0m\n", &[], {
@type "red";
@snap;
@snap_color;
});
#[cfg(unix)]
sk_test!(test_ansi_matching_on_stripped_text, @cmd "echo -e '\\x1b[32mgreen\\x1b[0m text\\n\\x1b[31mred\\x1b[0m text\\nplain text'", &["--ansi"], {
@capture[0] starts_with(">");
@lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain")));
@keys Str("text");
// Tiebreak will reorder items
@capture[2] contains("red text");
@capture[3] contains("green text");
@capture[4] contains("plain text");
@keys Ctrl(&Key('u')), Str("green");
@capture[2] contains("green");
@lines |l| (l.len() == 3);
// With --ansi, matching happens on the ANSI-stripped text and the tiebreak
// reorders the matches. The color snapshot confirms each item keeps its own
// (red / green) foreground after matching.
insta_test!(test_ansi_matching_on_stripped_text, @bytes b"\x1b[32mgreen\x1b[0m text\n\x1b[31mred\x1b[0m text\nplain text\n", &["--ansi"], {
@type "text";
@snap;
@snap_color;
@ctrl 'u';
@type "green";
@snap;
});
#[cfg(unix)]
sk_test!(test_ansi_flag_no_strip, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &["--ansi", "--no-strip-ansi", "--color", "current_match_bg:1,current_bg:2"], {
@capture[0] starts_with(">");
@lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain")));
@keys Key('d');
@capture[2] starts_with("> red");
@capture_colored[*] contains("mre\u{1b}");
@keys Enter;
@output[*] contains("mred\u{1b}");
// --no-strip-ansi only affects the accepted output (it keeps the escape
// sequences); on screen it renders identically to --ansi.
insta_test!(test_ansi_flag_no_strip, @bytes b"plain\n\x1b[31mred\x1b[0m\n\x1b[32mgreen\x1b[0m\n", &["--ansi", "--no-strip-ansi", "--color", "current_match_bg:1,current_bg:2"], {
@type "d";
@snap;
@snap_color;
});
insta_test!(test_prompt_ansi, ["a"], &["--prompt", "\x1b[1;34mprompt\x1b[0m nocol"], {
@snap;
@snap_color;
});

249
tests/cli.rs Normal file
View file

@ -0,0 +1,249 @@
//! Non-interactive CLI integration tests.
//!
//! These spawn the real `sk` binary in modes that exit without a TTY (filter
//! mode, shell-completion, man-page, and the various `--print-*` / output
//! flags). Because the binary is the instrumented `llvm-cov-target` build under
//! coverage, they exercise `bin/main.rs` and `skim.rs`'s non-interactive paths.
//!
//! The binary is spawned directly (no shell), so the tests are cross-platform.
//! `env_clear()` is intentionally NOT used so that `LLVM_PROFILE_FILE` (set by
//! cargo-llvm-cov) is inherited by the child and its coverage is recorded; only
//! the `SKIM_*` vars are removed explicitly.
#![allow(missing_docs, clippy::pedantic)]
#[allow(dead_code)]
mod common;
use std::io::Write;
use std::process::{Command, Stdio};
use common::SK;
fn sk_bin() -> &'static str {
SK
}
/// Spawn the binary with explicit argv and env, feeding `pipe_input` on stdin.
/// Returns `(exit_code, stdout, stderr)`.
fn run_sk_argv(pipe_input: &str, argv: &[&str], envs: &[(&str, &str)]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(sk_bin());
cmd.args(argv)
.env_remove("SKIM_DEFAULT_OPTIONS")
.env_remove("SKIM_DEFAULT_COMMAND")
.env_remove("SKIM_OPTIONS_FILE")
.stdin(if pipe_input.is_empty() {
Stdio::null()
} else {
Stdio::piped()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in envs {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn sk");
if !pipe_input.is_empty() {
// The callers write escapes (`\n`) the way `printf` once interpreted them.
let input = pipe_input
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\0", "\0");
child
.stdin
.take()
.expect("stdin piped")
.write_all(input.as_bytes())
.expect("write stdin");
}
let out = child.wait_with_output().expect("failed to wait on sk");
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
/// Convenience wrapper: tokenize a space-separated `args` string (shell-style)
/// and run with no extra env. Use [`run_sk_argv`] directly when an argument may
/// contain spaces (e.g. a temp-file path).
fn run_sk(pipe_input: &str, args: &str) -> (Option<i32>, String, String) {
let argv = shlex::split(args).expect("args should tokenize");
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
run_sk_argv(pipe_input, &refs, &[])
}
#[test]
fn filter_mode_prints_matches() {
// `-f` runs filter mode: no TUI, matched lines printed to stdout, exit 0.
let (code, stdout, _) = run_sk("apple\\nbanana\\ncherry", "-f a");
assert_eq!(code, Some(0));
// 'apple' and 'banana' contain 'a'; 'cherry' does not.
assert!(stdout.contains("apple"));
assert!(stdout.contains("banana"));
assert!(!stdout.contains("cherry"));
}
#[test]
fn filter_mode_empty_query_matches_all() {
let (code, stdout, _) = run_sk("one\\ntwo\\nthree", "-f ''");
assert_eq!(code, Some(0));
assert!(stdout.contains("one"));
assert!(stdout.contains("two"));
assert!(stdout.contains("three"));
}
#[test]
fn filter_mode_with_print_query() {
// --print-query prepends the query line to the output.
let (code, stdout, _) = run_sk("apple\\nbanana", "-f a --print-query");
assert_eq!(code, Some(0));
let mut lines = stdout.lines();
assert_eq!(lines.next(), Some("a"));
}
#[test]
fn filter_mode_with_print0() {
// --print0 separates output records with NUL instead of newline.
let (code, stdout, _) = run_sk("apple\\nbanana", "-f a --print0");
assert_eq!(code, Some(0));
assert!(stdout.contains('\0'));
}
#[test]
fn select_1_with_output_format() {
// --output-format renders the selected item through the printf branch.
let (code, stdout, _) = run_sk("1\\n2\\n3", "--select-1 -q 3 --output-format '{}'");
assert_eq!(code, Some(0));
assert!(stdout.contains('3'));
}
#[test]
fn filter_mode_output_format_current_item_is_empty() {
// `{}` expands to the *current* (highlighted) item, just like in previews.
// Filter mode has no interactive cursor, so there is no current item and the
// token expands to nothing — only the trailing record separator is emitted.
let (code, stdout, _) = run_sk("apple\\nbanana", "-f a --output-format '{}'");
assert_eq!(code, Some(0));
assert!(
stdout.trim().is_empty(),
"`{{}}` has no current item in filter mode (got {stdout:?})"
);
}
#[test]
fn filter_mode_output_format_all_items_token() {
// `{+}` expands to every matched item, so it works in filter mode where
// there is no single current item.
let (code, stdout, _) = run_sk("apple\\nbanana\\ncherry", "-f a --output-format '{+}'");
assert_eq!(code, Some(0));
assert!(stdout.contains("apple"), "got {stdout:?}");
assert!(stdout.contains("banana"), "got {stdout:?}");
assert!(
!stdout.contains("cherry"),
"non-matching item must be excluded (got {stdout:?})"
);
}
#[test]
fn select_1_writes_history_file() {
use std::io::Read;
// A history file records the query on exit (covers write_history_to_file in
// the real binary).
let hist = std::env::temp_dir().join(format!("sk_hist_{}", std::process::id()));
let hist_path = hist.to_str().unwrap();
// Pass argv explicitly: the temp path may contain spaces on some platforms.
let (code, _stdout, _) = run_sk_argv("1\\n2\\n3", &["--select-1", "-q", "3", "--history", hist_path], &[]);
assert_eq!(code, Some(0));
let mut contents = String::new();
std::fs::File::open(&hist)
.expect("history file should exist")
.read_to_string(&mut contents)
.unwrap();
assert!(contents.contains('3'));
let _ = std::fs::remove_file(&hist);
}
#[test]
fn select_1_print_current() {
// --print-current prints the current item line before the selected items.
let (code, stdout, _) = run_sk("1\\n2\\n3", "--select-1 -q 3 --print-current");
assert_eq!(code, Some(0));
assert!(stdout.contains('3'));
}
#[test]
fn log_file_initializes_logger() {
// --log-file routes env_logger to a file (covers init_logger's Pipe target
// and builder). SKIM_LOG=trace makes the run actually emit records.
let log = std::env::temp_dir().join(format!("sk_log_{}", std::process::id()));
let log_path = log.to_str().unwrap();
let (code, _stdout, _) = run_sk_argv(
"1\\n2\\n3",
&["--select-1", "-q", "3", "--log-file", log_path],
&[("SKIM_LOG", "trace")],
);
assert_eq!(code, Some(0));
// The log file was created by the file target.
assert!(log.exists());
let _ = std::fs::remove_file(&log);
}
#[test]
fn shell_completion_bash() {
// --shell bash generates a completion script and exits 0 without reading stdin.
let (code, stdout, _) = run_sk("", "--shell bash");
assert_eq!(code, Some(0));
assert!(!stdout.is_empty());
}
#[test]
fn shell_completion_with_key_bindings() {
// --shell zsh together with --shell-bindings emits bindings too.
let (code, stdout, _) = run_sk("", "--shell zsh --shell-bindings");
assert_eq!(code, Some(0));
assert!(!stdout.is_empty());
}
#[test]
fn man_page_generation() {
// --man writes the man page to stdout and exits 0.
let (code, stdout, _) = run_sk("", "--man");
assert_eq!(code, Some(0));
assert!(stdout.contains(".TH") || stdout.to_lowercase().contains("skim"));
}
#[test]
fn select_1_prints_all_metadata_flags() {
// A single matching item with select-1 exits without the TUI and prints all
// the requested metadata lines (query, cmd, header, score).
let (code, stdout, _) = run_sk(
"1\\n2\\n3",
"--select-1 -q 3 --print-query --print-cmd --print-header --print-score",
);
assert_eq!(code, Some(0));
assert!(stdout.contains('3'));
}
#[test]
fn version_flag_exits_zero() {
let (code, stdout, _) = run_sk("", "--version");
assert_eq!(code, Some(0));
assert!(stdout.to_lowercase().contains("sk") || !stdout.is_empty());
}
#[test]
fn help_flag_exits_zero() {
let (code, stdout, _) = run_sk("", "--help");
assert_eq!(code, Some(0));
assert!(!stdout.is_empty());
}
#[test]
fn invalid_flag_exits_with_error() {
// An unknown flag makes clap print usage and exit non-zero (main()'s
// `from_env` error path).
let (code, _stdout, stderr) = run_sk("", "--definitely-not-a-real-flag");
assert_ne!(code, Some(0));
assert!(!stderr.is_empty());
}

View file

@ -161,6 +161,61 @@ impl TestHarness {
self.skim.tui_ref().backend().to_string()
}
/// Get a representation of the buffer's *styling* for snapshot testing.
///
/// `buffer_view()` only captures cell text, so it cannot verify color. This
/// view instead lists every run of consecutive cells that share a
/// non-default style, as `(row, start..end) "text" fg=… bg=… mod=…`. Cells
/// with the default style (`fg=Reset bg=Reset`, no modifier) are omitted so
/// the snapshot stays focused on what is actually colored.
pub fn color_view(&self) -> String {
use ratatui::style::{Color, Modifier};
let buffer = self.skim.tui_ref().backend().buffer();
let area = buffer.area;
let cell_at = |x: u16, y: u16| buffer.cell((x, y)).expect("cell within buffer area");
let is_default = |fg: Color, bg: Color, md: Modifier| fg == Color::Reset && bg == Color::Reset && md.is_empty();
let mut out = String::new();
for y in 0..area.height {
let mut x = 0;
while x < area.width {
let cell = cell_at(x, y);
let (fg, bg, md) = (cell.fg, cell.bg, cell.modifier);
if is_default(fg, bg, md) {
x += 1;
continue;
}
// Merge the run of cells sharing this exact style.
let start = x;
let mut text = String::new();
while x < area.width {
let c = cell_at(x, y);
if c.fg != fg || c.bg != bg || c.modifier != md {
break;
}
text.push_str(c.symbol());
x += 1;
}
let mut attrs = Vec::new();
if fg != Color::Reset {
attrs.push(format!("fg={fg:?}"));
}
if bg != Color::Reset {
attrs.push(format!("bg={bg:?}"));
}
if !md.is_empty() {
attrs.push(format!("mod={md:?}"));
}
out.push_str(&format!("({y}, {start}..{x}) {text:?} {}\n", attrs.join(" ")));
}
}
if out.is_empty() {
out.push_str("(no styled cells)\n");
}
out
}
/// Prepare for taking a snapshot by waiting for preview and processing heartbeat.
///
/// This ensures the state is up-to-date before taking a snapshot.
@ -532,6 +587,32 @@ macro_rules! snap {
};
}
/// Like [`snap!`], but snapshots the buffer's *styling* (via
/// [`TestHarness::color_view`]) instead of its text. Color snapshots live in
/// their own files (`{test}@color{NNN}.snap`) so they never collide with the
/// text snapshots taken by `snap!` / `@snap`.
#[macro_export]
macro_rules! snap_color {
($harness:ident, $desc:expr, $count:expr) => {{
$harness.prepare_snap()?;
let __cv = $harness.color_view();
insta::with_settings!({
description => $desc,
snapshot_suffix => format!("color{:03}", $count),
omit_expression => true,
}, {
insta::assert_snapshot!(__cv);
});
}};
($harness:ident, $desc:expr) => {{
$harness.prepare_snap()?;
let __cv = $harness.color_view();
insta::with_settings!({ description => $desc, omit_expression => true }, {
insta::assert_snapshot!(__cv);
});
}};
}
/// Macro for writing compact insta snapshot tests.
///
/// # Usage
@ -731,6 +812,22 @@ macro_rules! insta_test {
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
// @snap_color - like @snap, but captures cell styling (color) into its own
// snapshot file. Shares the snapshot counter with @snap; the description is
// NOT cleared so a following @snap reflects the same since-last-snap commands.
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @snap_color; $($rest:tt)*) => {
{
$count += 1;
let __snap_desc = if $cmds.is_empty() {
$base.clone()
} else {
format!("{}\nafter:\n {}", $base, $cmds.join("\n "))
};
$crate::snap_color!($h, &__snap_desc, $count);
}
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
// @char - send single character
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @char $c:expr ; $($rest:tt)*) => {
$cmds.push(concat!("@char ", stringify!($c)));

View file

@ -4,16 +4,17 @@ pub mod insta;
#[cfg(unix)]
pub mod tmux;
/// Raw binary path. Use `Command::new(SK)` to spawn directly; apply
/// `SKIM_ENV_REMOVES` via `.env_remove()` on the command when needed.
/// For shell-command strings (e.g. sent to tmux) prepend `SKIM_SHELL_ENV_CLEAR`.
#[cfg(all(unix, debug_assertions, coverage))]
pub static SK: &str =
"SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/llvm-cov-target/debug/sk";
pub static SK: &str = "./target/llvm-cov-target/debug/sk";
#[cfg(all(unix, debug_assertions, not(coverage)))]
pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/debug/sk";
pub static SK: &str = "./target/debug/sk";
#[cfg(all(unix, not(debug_assertions), coverage))]
pub static SK: &str =
"SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/llvm-cov-target/release/sk";
pub static SK: &str = "./target/llvm-cov-target/release/sk";
#[cfg(all(unix, not(debug_assertions), not(coverage)))]
pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/release/sk";
pub static SK: &str = "./target/release/sk";
#[cfg(all(windows, debug_assertions, coverage))]
pub static SK: &str = r".\target\llvm-cov-target\debug\sk.exe";
@ -23,3 +24,14 @@ pub static SK: &str = r".\target\debug\sk.exe";
pub static SK: &str = r".\target\llvm-cov-target\release\sk.exe";
#[cfg(all(windows, not(debug_assertions), not(coverage)))]
pub static SK: &str = r".\target\release\sk.exe";
/// Environment variables that sk tests must clear so `SKIM_DEFAULT_OPTIONS`
/// and friends don't leak in from the outer test environment.
pub const SKIM_ENV_REMOVES: &[&str] = &["SKIM_DEFAULT_OPTIONS", "SKIM_DEFAULT_COMMAND", "SKIM_OPTIONS_FILE"];
/// Shell-level env-clearing prefix for embedding sk in a shell command string
/// (e.g. commands sent to a tmux pane via `send-keys`).
#[cfg(unix)]
pub const SKIM_SHELL_ENV_CLEAR: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ";
#[cfg(windows)]
pub const SKIM_SHELL_ENV_CLEAR: &str = "";

View file

@ -11,11 +11,12 @@ use rand::distr::Alphanumeric;
use tempfile::{NamedTempFile, TempDir, tempdir};
use which::which;
use crate::common::SK;
use crate::common::{SK, SKIM_SHELL_ENV_CLEAR};
pub fn sk(outfile: &str, opts: &[&str]) -> String {
format!(
"{} {} > {}.part; mv {}.part {}",
"{}{} {} > {}.part; mv {}.part {}",
SKIM_SHELL_ENV_CLEAR,
SK,
opts.join(" "),
outfile,

View file

@ -1,55 +1,22 @@
#![allow(missing_docs, clippy::pedantic)]
#![cfg(unix)]
#[allow(dead_code)]
#[macro_use]
mod common;
use common::tmux::Keys::*;
sk_test!(highlight_match, @cmd "echo -e 'apple\\nbanana\\ngrape'", &["--color=matched:9,current_match:1"], {
@capture[2] contains("apple");
@keys Str("pp");
// Wait for filtering to complete - should only show apple
@capture[1] contains("1/3");
@capture[2] contains("apple");
@capture_colored[2] contains("a");
@capture_colored[2] contains("pp");
@capture_colored[2] contains("le");
// Check that the 'p' characters in "apple" have highlighting color codes
@capture_colored[2] contains("\x1b[38;5;1m");
@capture_colored[2] contains("pp\x1b[");
@keys Enter;
@output[0] eq("apple");
// Matched query characters get the `current_match` color (1) on the selected row
// and `matched` (9) elsewhere. `@snap_color` captures the per-cell styling so the
// highlight is actually asserted, cross-platform.
insta_test!(highlight_match, ["apple", "banana", "grape"], &["--color=matched:9,current_match:1"], {
@type "pp";
@snap;
@snap_color;
});
sk_test!(highlight_split_match, @cmd "echo -e 'apple\\nbanana\\ngrape'", &["--color=matched:9,current_match:1,current_bg:236"], {
@capture[2] contains("apple");
@keys Str("aaa");
// Wait for filtering to complete - should only show banana
@capture[1] contains("1/3");
@capture[2] contains("banana");
@capture_colored[2] contains("b");
@capture_colored[2] contains("a");
@capture_colored[2] contains("n");
// Check that matched characters have the current_match foreground color (color 1)
@capture_colored[2] contains("\x1b[38;5;1m");
// Check that the current line has the current background color (color 236)
@capture_colored[2] contains("\x1b[48;5;236m");
// Check that there are 3 matched 'a' characters with foreground color 1
let match_fg_pattern = "\x1b[38;5;1ma";
@capture_colored[2] matches(match_fg_pattern).count() == 3;
@keys Enter;
@output[0] eq("banana");
// With `current_bg:236`, the selected row also carries that background while its
// matched characters keep the `current_match` foreground (1).
insta_test!(highlight_split_match, ["apple", "banana", "grape"], &["--color=matched:9,current_match:1,current_bg:236"], {
@type "aaa";
@snap;
@snap_color;
});

View file

@ -1,95 +0,0 @@
#![allow(missing_docs, clippy::pedantic)]
#![cfg(unix)]
#[allow(dead_code)]
mod common;
use common::tmux::Keys::*;
use common::tmux::TmuxController;
use std::fs::File;
use std::io::{Read, Result, Write};
use std::path::Path;
#[test]
fn query_history() -> Result<()> {
let mut tmux = TmuxController::new()?;
let histfile = tmux.tempfile()?;
File::create(&histfile)?.write_all(b"a\nb\nc")?;
tmux.start_sk(Some("echo -e -n 'a\\nb\\nc'"), &["--history", &histfile])?;
tmux.until(|l| l[0].starts_with(">"))?;
tmux.send_keys(&[Ctrl(&Key('p'))])?;
tmux.until(|l| l[0].trim() == "> c")?;
tmux.send_keys(&[Ctrl(&Key('p'))])?;
tmux.until(|l| l[0].trim() == "> b")?;
tmux.send_keys(&[Ctrl(&Key('p'))])?;
tmux.until(|l| l[0].trim() == "> a")?;
tmux.send_keys(&[Ctrl(&Key('n'))])?;
tmux.until(|l| l[0].trim() == "> b")?;
tmux.send_keys(&[Key('n')])?;
tmux.until(|l| l[0].trim() == "> bn")?;
tmux.send_keys(&[Enter])?;
tmux.until(|_| {
let mut buf = String::new();
File::open(Path::new(&histfile))
.unwrap()
.read_to_string(&mut buf)
.unwrap();
println!("{}", buf);
buf == "a\nb\nc\nbn"
})?;
Ok(())
}
#[test]
fn cmd_history() -> Result<()> {
let mut tmux = TmuxController::new()?;
let histfile = tmux.tempfile()?;
File::create(&histfile)?.write_all(b"a\nb\nc")?;
tmux.start_sk(
Some("echo -e -n 'a\\nb\\nc'"),
&["-i", "-c", "'echo {}'", "--cmd-history", &histfile],
)?;
tmux.until(|l| l[0].starts_with("c>"))?;
tmux.send_keys(&[Ctrl(&Key('p'))])?;
tmux.until(|l| l[0].trim() == "c> c")?;
tmux.send_keys(&[Ctrl(&Key('p'))])?;
tmux.until(|l| l[0].trim() == "c> b")?;
tmux.send_keys(&[Ctrl(&Key('p'))])?;
tmux.until(|l| l[0].trim() == "c> a")?;
tmux.send_keys(&[Ctrl(&Key('n'))])?;
tmux.until(|l| l[0].trim() == "c> b")?;
tmux.send_keys(&[Key('n')])?;
tmux.until(|l| l[0].trim() == "c> bn")?;
tmux.send_keys(&[Enter])?;
tmux.until(|_| {
let mut buf = String::new();
File::open(Path::new(&histfile))
.unwrap()
.read_to_string(&mut buf)
.unwrap();
println!("{}", buf);
buf == "a\nb\nc\nbn"
})?;
Ok(())
}

View file

@ -14,14 +14,14 @@ use std::process::{Child, Command, Stdio};
use common::tmux::TmuxController;
use crate::common::SK;
use crate::common::{SK, SKIM_ENV_REMOVES};
fn connect(name: &str) -> Result<Child> {
Command::new("/bin/sh")
.arg("-c")
.arg(format!("{SK} --remote {name}"))
.stdin(Stdio::piped())
.spawn()
let mut cmd = Command::new(SK);
for var in SKIM_ENV_REMOVES {
cmd.env_remove(var);
}
cmd.args(["--remote", name]).stdin(Stdio::piped()).spawn()
}
fn send(child: &mut Child, msg: &str) -> Result<()> {
let mut b = msg.bytes().collect::<Vec<_>>();

View file

@ -203,19 +203,19 @@ insta_test!(opt_nth_range_dec, ["f1,f2,f3,f4"], &["--delimiter", ",", "--nth", "
@snap;
});
insta_test!(opt_hscroll_begin, [&format!("b{}", &["a"; 1000].join(""))], &["-q", "b"], {
insta_test!(opt_hscroll_begin, [&format!("b{}", ["a"; 1000].join(""))], &["-q", "b"], {
@snap;
});
insta_test!(opt_hscroll_middle, [&format!("{}b{}", &["a"; 1000].join(""), &["a"; 1000].join(""))], &["-q", "b"], {
insta_test!(opt_hscroll_middle, [&format!("{}b{}", ["a"; 1000].join(""), ["a"; 1000].join(""))], &["-q", "b"], {
@snap;
});
insta_test!(opt_hscroll_end, [&format!("{}b", &["a"; 1000].join(""))], &["-q", "b"], {
insta_test!(opt_hscroll_end, [&format!("{}b", ["a"; 1000].join(""))], &["-q", "b"], {
@snap;
});
insta_test!(opt_no_hscroll, [&format!("{}b", &["a"; 1000].join(""))], &["-q", "b", "--no-hscroll"], {
insta_test!(opt_no_hscroll, [&format!("{}b", ["a"; 1000].join(""))], &["-q", "b", "--no-hscroll"], {
@snap;
});
@ -698,3 +698,111 @@ insta_test!(opt_scrollbar_custom_thumb, SCROLLBAR_ITEMS, &["--info=hidden", "--s
insta_test!(opt_scrollbar_reverse, SCROLLBAR_ITEMS, &["--info=hidden", "--layout=reverse"], {
@snap;
});
// Basic rendering: prompt, counters, and the item list.
insta_test!(vanilla_basic, ["1", "2", "3"], &[], {
@snap;
});
// --read0 splits the NUL-separated stream into three items.
insta_test!(opt_read0, @bytes b"a\0b\0c", &["--read0"], {
@snap;
});
// A NUL delimiter with --with-nth shows only the second field of the single item.
insta_test!(opt_null_delimiter_with_nth, @bytes b"a\0b\0c", &[r"--delimiter", r"\x00", "--with-nth", "2"], {
@snap;
});
// A NUL delimiter with --nth restricts matching to the second field ("b"):
// typing 'c' matches nothing, while 'b' matches the whole item.
insta_test!(opt_null_delimiter_nth, @bytes b"a\0b\0c", &[r"--delimiter", r"\x00", "--nth", "2"], {
@snap;
@char 'c';
@snap;
@key Backspace;
@char 'b';
@snap;
});
// --pre-select-file marks items "b" and "c" as selected at startup.
#[test]
fn opt_pre_select_file() -> color_eyre::Result<()> {
use std::io::Write;
let mut pre_select = tempfile::NamedTempFile::new()?;
pre_select.write_all(b"b\nc")?;
let path = pre_select.path().to_str().expect("utf-8 temp path");
let options = common::insta::parse_options(&["-m", "--pre-select-file", path]);
let mut h = common::insta::enter_items(["a", "b", "c"], options)?;
snap!(
h,
"input: items [\"a\", \"b\", \"c\"]\noptions: -m --pre-select-file <file with b,c>"
);
Ok(())
}
// Reserved fzf-compatibility options must be accepted by the parser.
#[test]
fn opt_reserved_options_parse() {
let reserved = [
"--extended",
"--literal",
"--no-mouse",
"--hscroll-off=10",
"--filepath-word",
"--jump-labels=CHARS",
"--no-bold",
"--history-size=10",
];
for option in reserved {
// parse_options panics on a parse/build failure, which is the assertion.
let _ = common::insta::parse_options(&[option]);
}
}
// A flag accepted more than once (long/short, with or without `=`) must parse.
#[test]
fn opt_multiple_flags_parse() {
let combos = [
"--bind=ctrl-a:cancel --bind ctrl-b:cancel",
"--tiebreak=begin --tiebreak=score",
"--cmd asdf --cmd find",
"--query asdf -q xyz",
"--delimiter , --delimiter . -d ,",
"--nth 1,2 --nth=1,3 -n 1,3",
"--with-nth 1,2 --with-nth=1,3",
"-I {} -I XX",
"--color base --color light",
"--margin 30% --margin 0",
"--min-height 30% --min-height 10",
"--preview 'ls {}' --preview 'cat {}'",
"--preview-window up --preview-window down",
"--multi -m",
"--no-multi --no-multi",
"--tac --tac",
"--ansi --ansi",
"--exact -e",
"--regex --regex",
"--literal --literal",
"--no-mouse --no-mouse",
"--cycle --cycle",
"--no-hscroll --no-hscroll",
"--filepath-word --filepath-word",
"--inline-info --inline-info",
"--no-bold --no-bold",
"--print-query --print-query",
"--print-cmd --print-cmd",
"--print0 --print0",
"--sync --sync",
"--extended --extended",
"--no-sort --no-sort",
"--exit-0 --exit-0",
];
for combo in combos {
let args = shlex::split(combo).expect("valid shell tokens");
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = common::insta::parse_options(&refs);
}
}

View file

@ -46,13 +46,8 @@ fn get_tmux_cmd(outfile: &str) -> Result<String> {
fn tmux_via_skim_default_options() -> Result<()> {
let tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
// Run sk with SKIM_DEFAULT_OPTIONS=--tmux inline (bypassing the SK constant
// which always clears SKIM_DEFAULT_OPTIONS).
let sk_bin = crate::common::SK
.split_whitespace()
.last()
.expect("SK must have a binary path");
let cmd = format!("SKIM_DEFAULT_OPTIONS='--tmux' {sk_bin}");
// Run sk with SKIM_DEFAULT_OPTIONS=--tmux set inline so the popup path is exercised.
let cmd = format!("SKIM_DEFAULT_OPTIONS='--tmux' {}", crate::common::SK);
tmux.send_keys(&[Str(&cmd), Enter])?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;

View file

@ -0,0 +1,29 @@
---
source: tests/ansi.rs
description: "input: bytes b\"plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m\\n\"\noptions: \nafter:\n @type \"red\""
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
"> ?[31mred?[0m "
" 1/3 0/0"
"> red "
cursor: (24, 6)

View file

@ -0,0 +1,12 @@
---
source: tests/ansi.rs
description: "input: bytes b\"plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m\\n\"\noptions: "
---
(21, 0..1) ">" fg=Indexed(161)
(21, 1..2) " " fg=Indexed(168)
(21, 2..7) "?[31m" bg=Indexed(236)
(21, 7..10) "red" fg=Indexed(151) bg=Indexed(236)
(21, 10..14) "?[0m" bg=Indexed(236)
(22, 0..5) " 1/3" fg=Indexed(144)
(22, 77..80) "0/0" fg=Indexed(144)
(23, 0..2) "> " fg=Indexed(110)

Some files were not shown because too many files have changed in this diff Show more