feat: use a separate thread pool for Matcher runs (#961)

* Initial commit

* Disable Matcher when query is empty

* Revert

* Cleanup

* Use par_chunks for faster search

* Cleanup

* Skip updating atomic on every iter

* Item index unnecessary?

* Cleanup

* Build thread pool at App level

* Initial commit

* Disable Matcher when query is empty

* Revert

* Cleanup

* Use par_chunks for faster search

* Cleanup

* Skip updating atomic on every iter

* Item index unnecessary?

* Cleanup

* Build thread pool at App level

* Make suggested improvements

* chore: cleanup after rebase

* refactor: rewrite insta test harness to use fine-grained Skim:: methods

Split `impl Skim` into a generic `impl<Backend> Skim<Backend>` block so
that `Skim::init()`, `Skim::start()`, `Skim::tick()`, etc. work with
any backend, not just the default CrosstermBackend.

New public API on Skim<B>:
- `init_tui_with(tui)` – inject a caller-provided TUI (e.g. TestBackend)
- `app()` / `app_mut()` – access the application state
- `tui_ref()` / `tui_mut()` – access the TUI
- `app_and_tui()` – simultaneous mutable access to both (avoids borrow
  conflicts in render and handle_event calls)
- `final_event()` – inspect the quit event

TestHarness now wraps `Skim<TestBackend>` and initializes via
`Skim::init()` + `Skim::init_tui_with()`, sharing the production
init path (theme, reader, command expansion) instead of duplicating it.

https://claude.ai/code/session_016PtHKc9YVEpHftDxG5Nger

* chore: make insta harness more realistic

* Cleanup merge errors

* Remove duplicate check

* Cleanup

* No need to clone twice

* fix: fix thread pool race condition

---------

Co-authored-by: Loric ANDRE <loric.andre@pm.me>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
kimono-koans 2026-02-15 10:34:46 -06:00 committed by GitHub
parent 19bfd34aaf
commit ad91558749
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 391 additions and 258 deletions

View file

@ -393,6 +393,18 @@ impl Skim {
Ok(output)
}
/// Initialize the TUI with the default crossterm backend, but do not enter it yet
pub fn init_tui(&mut self) -> Result<()> {
self.tui = Some(tui::Tui::new_with_height(self.height)?);
Ok(())
}
}
impl<Backend: ratatui::backend::Backend + 'static> Skim<Backend>
where
Backend::Error: Send + Sync + 'static,
{
/// Initialize skim, without starting anything yet
pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
let height = Size::try_from(options.height.as_str())?;
@ -446,10 +458,104 @@ impl Skim {
self.app.restart_matcher(true);
}
/// Initialize the TUI, but do not enter it yet
pub fn init_tui(&mut self) -> Result<()> {
self.tui = Some(tui::Tui::new_with_height(self.height)?);
Ok(())
/// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
///
/// This encapsulates the reload logic from the main event loop so it can
/// be reused by test harnesses without reimplementing it.
pub fn handle_reload(&mut self, new_cmd: &str) {
debug!("reloading with cmd {new_cmd}");
// Kill the current reader
if let Some(rc) = self.reader_control.as_mut() {
rc.kill()
}
// Clear items
self.app.item_pool.clear();
// Clear displayed items unless no_clear_if_empty is set
if !self.app.options.no_clear_if_empty {
self.app.item_list.clear();
}
self.app.restart_matcher(true);
// Start a new reader with the new command
self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
self.reader_done = false;
}
/// Check if the reader has finished and restart the matcher if needed.
///
/// This encapsulates the reader-status check from the main event loop
/// so it can be reused by test harnesses.
///
/// Returns `true` if the reader has completed.
pub fn check_reader(&mut self) -> bool {
if self.reader_control.as_ref().is_some_and(|rc| rc.is_done()) && !self.reader_done {
self.reader_done = true;
self.app.restart_matcher(false);
true
} else {
false
}
}
/// Returns `true` if the reader is done (has finished producing items).
pub fn reader_done(&self) -> bool {
self.reader_done && self.reader_control.as_ref().is_none_or(|rc| rc.is_done())
}
/// Initialize the TUI with a caller-provided instance.
///
/// Use this instead of [`init_tui()`](Skim::init_tui) when you need a
/// non-default backend (e.g. `TestBackend` for snapshot tests).
pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
self.tui = Some(tui);
}
/// Returns a shared reference to the application state.
pub fn app(&self) -> &App {
&self.app
}
/// Returns a mutable reference to the application state.
pub fn app_mut(&mut self) -> &mut App {
&mut self.app
}
/// Returns a shared reference to the TUI.
///
/// # Panics
///
/// Panics if the TUI has not been initialized yet.
pub fn tui_ref(&self) -> &Tui<Backend> {
self.tui.as_ref().expect("TUI needs to be initialized before access")
}
/// Returns a mutable reference to the TUI.
///
/// # Panics
///
/// Panics if the TUI has not been initialized yet.
pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
self.tui.as_mut().expect("TUI needs to be initialized before access")
}
/// Returns mutable references to both the app and the TUI simultaneously.
///
/// This is useful when you need to call `app.handle_event(tui, ...)` or
/// `tui.draw(|frame| frame.render_widget(app, ...))`, which require
/// disjoint mutable borrows of both fields.
///
/// # Panics
///
/// Panics if the TUI has not been initialized yet.
pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
(
&mut self.app,
self.tui.as_mut().expect("TUI needs to be initialized before access"),
)
}
/// Returns a shared reference to the final event that caused skim to quit.
pub fn final_event(&self) -> &Event {
&self.final_event
}
/// Returns a clone of the TUI event sender.
@ -458,7 +564,7 @@ impl Skim {
/// to the running skim instance from outside the event loop. The sender
/// is cheap to clone and can be moved into async blocks or other tasks.
///
/// Must be called after [`init_tui()`](Self::init_tui).
/// Must be called after [`init_tui()`](Skim::init_tui).
pub fn event_sender(&self) -> tokio::sync::mpsc::Sender<Event> {
self.tui
.as_ref()
@ -613,28 +719,13 @@ impl Skim {
// Handle reload event separately
if let Event::Reload(new_cmd) = &evt {
debug!("reloading with cmd {new_cmd}");
// Kill the current reader
if let Some(rc) = self.reader_control.as_mut() { rc.kill() }
// Clear items
self.app.item_pool.clear();
// Clear displayed items unless no_clear_if_empty is set
// (in which case the item_list will handle keeping stale items)
if !self.app.options.no_clear_if_empty {
self.app.item_list.clear();
}
self.app.restart_matcher(true);
// Start a new reader with the new command (no source, using cmd)
self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
self.reader_done = false;
self.handle_reload(&new_cmd.clone());
} else {
self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)?;
}
// Check reader status and update
if self.reader_control.as_ref().is_some_and(|rc| rc.is_done()) && !self.reader_done {
self.app.restart_matcher(false);
}
self.check_reader();
}
_ = async {
match matcher_interval {

View file

@ -1,8 +1,8 @@
//! This module contains the matching coordinator
use rayon::ThreadPool;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use rayon::prelude::*;
@ -148,7 +148,7 @@ impl Matcher {
///
/// The callback is invoked when matching is complete with the matched items.
/// Returns a MatcherControl that can be used to monitor progress or stop the matcher.
pub fn run<C>(&self, query: &str, item_pool: Arc<ItemPool>, callback: C) -> MatcherControl
pub fn run<C>(&self, query: &str, item_pool: Arc<ItemPool>, thread_pool: &ThreadPool, callback: C) -> MatcherControl
where
C: Fn(Vec<MatchedItem>) + Send + 'static,
{
@ -162,39 +162,51 @@ impl Matcher {
let processed_clone = processed.clone();
let matched = Arc::new(AtomicUsize::new(0));
let matched_clone = matched.clone();
let mut matched_items = Vec::new();
thread::spawn(move || {
let items = item_pool.take();
trace!("matcher start, total: {}", items.len());
let result: Result<Vec<_>, _> = items
// Take items synchronously before spawning to avoid a race condition:
// if we took items inside the spawned closure, a subsequent restart_matcher()
// could call kill() + reset() before the old closure runs, causing the old
// closure to re-take items that should belong to the new matcher.
let items = item_pool.take();
trace!("matcher start, total: {}", items.len());
thread_pool.spawn(move || {
let matched_items: Vec<MatchedItem> = items
.into_par_iter()
.enumerate()
.filter_map(|(_, item)| {
processed.fetch_add(1, Ordering::Relaxed);
.chunks(8196)
.take_any_while(|_chunk| {
if interrupt.load(Ordering::Relaxed) {
stopped.store(true, Ordering::Relaxed);
Some(Err("matcher killed"))
} else if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
matched.fetch_add(1, Ordering::Relaxed);
// item is Arc but we get &Arc from iterator, so one clone is needed
Some(Ok(MatchedItem {
item: item.clone(),
rank: match_result.rank,
matched_range: Some(match_result.matched_range),
}))
} else {
None
return false;
}
true
})
.map(|chunk| {
processed.fetch_add(chunk.len(), Ordering::Relaxed);
let matched_chunk: Vec<MatchedItem> = chunk
.into_iter()
.filter_map(|item| {
matcher_engine.match_item(item.as_ref()).map(|match_result| {
// item is Arc but we get &Arc from iterator, so one clone is needed
MatchedItem {
item,
rank: match_result.rank,
matched_range: Some(match_result.matched_range),
}
})
})
.collect();
matched.fetch_add(matched_chunk.len(), Ordering::Relaxed);
matched_chunk
})
.flatten_iter()
.collect();
if let Ok(items) = result {
matched_items = items;
if !interrupt.load(Ordering::SeqCst) {
trace!("matcher stop, total matched: {}", matched_items.len());
}
if !interrupt.load(Ordering::Relaxed) {
callback(matched_items);
}
stopped.store(true, Ordering::Relaxed);

View file

@ -27,6 +27,15 @@ use ratatui::crossterm::event::KeyCode::Char;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::Backend;
use ratatui::widgets::Widget;
use rayon::ThreadPool;
use std::sync::LazyLock;
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
std::thread::available_parallelism()
.ok()
.map(|inner| inner.get())
.unwrap_or_else(|| 0)
});
use super::{input, preview};
@ -37,6 +46,8 @@ const HIDE_GRACE_MS: u128 = 500;
pub struct App {
/// Pool of items to be filtered
pub item_pool: Arc<ItemPool>,
/// Separate thread pool for use by skim
pub thread_pool: Arc<ThreadPool>,
/// Whether the application should quit
pub should_quit: bool,
@ -284,6 +295,12 @@ impl Default for App {
preview: Preview::from_options(&opts, theme.clone()),
header: Header::from_options(&opts, theme.clone()),
item_list: ItemList::from_options(&opts, theme.clone()),
thread_pool: Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(*NUM_THREADS)
.build()
.unwrap(),
),
item_pool: Arc::default(),
theme,
should_quit: false,
@ -324,6 +341,12 @@ impl App {
input: Input::from_options(&options, theme.clone()),
preview: Preview::from_options(&options, theme.clone()),
header: Header::from_options(&options, theme.clone()),
thread_pool: Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(*NUM_THREADS)
.build()
.unwrap(),
),
item_pool: Arc::new(ItemPool::from_options(&options)),
item_list: ItemList::from_options(&options, theme.clone()),
theme,
@ -1180,11 +1203,12 @@ impl App {
&self.input
};
let item_pool = self.item_pool.clone();
let thread_pool = &self.thread_pool;
let processed_items = self.item_list.processed_items.clone();
let no_sort = self.options.no_sort;
self.item_pool.reset();
self.matcher_control = self.matcher.run(query, item_pool.clone(), move |mut matches| {
self.matcher_control = self.matcher.run(query, item_pool, thread_pool, move |mut matches| {
debug!("Got {} results from matcher, sending to item list...", matches.len());
// Send matched items directly (header_lines are now handled by the Header widget)

View file

@ -79,13 +79,13 @@ insta_test!(bind_set_query_to_itself, ["a", "b", "c"], &["--bind", "ctrl-a:set-q
@snap;
});
insta_test!(bind_toggle_interactive, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i"], {
insta_test!(bind_toggle_interactive, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i", "--cmd", "true"], {
@snap;
@ctrl 'a';
@snap;
});
insta_test!(bind_toggle_interactive_queries, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i", "--query", "normal", "--cmd-query", "interactive"], {
insta_test!(bind_toggle_interactive_queries, @interactive, &["--bind", "ctrl-a:toggle-interactive", "-i", "--cmd", "true", "--query", "normal", "--cmd-query", "interactive"], {
@snap;
@ctrl 'a';
@snap;

View file

@ -1,33 +1,33 @@
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::io::Cursor;
use clap::Parser;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use skim::{
field::FieldRange,
helper::item::DefaultSkimItem,
Skim, SkimItemReceiver,
prelude::*,
theme::ColorTheme,
tui::{App, Event, Tui, event::Action},
tui::{Event, Tui, event::Action},
};
/// A test harness for running skim TUI tests with insta snapshots.
///
/// This struct wraps the TUI and App, providing an event-driven interface
/// that mirrors how the real application works. Events are sent via the
/// event channel and processed through the app's event loop.
/// This struct wraps a [`Skim<TestBackend>`] instance, providing a synchronous,
/// event-driven interface that mirrors how the real application works. Events are
/// sent via the event channel and processed through the app's event loop.
///
/// The harness reuses as much of the production code path as possible:
/// - Items are loaded through the same `Reader` + `SkimItemReader` pipeline as
/// the real application
/// - Reload events use `Skim::handle_reload()`, the same logic as the production
/// event loop
/// - Reader completion is checked via `Skim::check_reader()`, identical to
/// production
pub struct TestHarness {
/// The TUI instance with TestBackend
pub tui: Tui<TestBackend>,
/// The application state
pub app: App,
/// The Skim instance backed by a TestBackend for snapshot testing.
pub skim: Skim<TestBackend>,
/// Tokio runtime for async operations (preview commands, etc.)
pub runtime: tokio::runtime::Runtime,
/// The exit status of the last command executed (for @cmd tests)
pub exit_status: Option<i32>,
/// The final event that caused the app to quit (for determining exit code)
pub final_event: Option<Event>,
}
@ -39,40 +39,48 @@ impl TestHarness {
/// in `lib.rs`. It drains the event_rx channel and calls `app.handle_event()`
/// for each event, mimicking the actual application behavior.
///
/// For `Event::Reload`, it executes the command and restarts the reader,
/// just like the main event loop does.
/// For `Event::Reload`, it delegates to `Skim::handle_reload()` — the same
/// method used by the production event loop.
pub fn tick(&mut self) -> Result<()> {
// Process all pending events
while let Ok(event) = self.tui.event_rx.try_recv() {
self.process_event(event)?;
// Drain-and-process in a loop so events queued during processing
// are picked up on the next iteration.
loop {
let mut events = Vec::new();
while let Ok(event) = self.skim.tui_mut().event_rx.try_recv() {
events.push(event);
}
if events.is_empty() {
break;
}
for event in events {
self.process_event(event)?;
}
}
Ok(())
}
/// Process a single event through the app's event handler.
/// Process a single event through the same logic as the production event loop.
///
/// This handles special events like Reload that need extra processing
/// beyond what `app.handle_event()` does.
/// `Event::Reload` is handled via `Skim::handle_reload()` — the exact same
/// method used in production. All other events are forwarded to
/// `app.handle_event()`.
fn process_event(&mut self, event: Event) -> Result<()> {
// Handle reload event specially - this is what the main loop does
if let Event::Reload(ref new_cmd) = event {
// Clear items
self.app.item_pool.clear();
if !self.app.options.no_clear_if_empty {
self.app.item_list.clear();
}
// Run the command and add items
self.run_command_internal(new_cmd)?;
self.app.restart_matcher(true);
let new_cmd = new_cmd.clone();
self.skim.handle_reload(&new_cmd);
} else {
// Let the app handle the event (this may queue more events)
// Enter the runtime context so that tokio::spawn() calls work
let _guard = self.runtime.enter();
self.app.handle_event(&mut self.tui, &event)?;
let (app, tui) = self.skim.app_and_tui();
app.handle_event(tui, &event)?;
}
// Check reader status, just like the production event loop
self.skim.check_reader();
// Track if app should quit and what the final event was
if self.app.should_quit && self.final_event.is_none() {
if self.skim.app().should_quit && self.final_event.is_none() {
self.final_event = Some(event);
}
@ -83,7 +91,7 @@ impl TestHarness {
///
/// This queues an event for processing. Call `tick()` to process queued events.
pub fn send(&mut self, event: Event) -> Result<()> {
self.tui.event_tx.try_send(event)?;
self.skim.tui_mut().event_tx.try_send(event)?;
Ok(())
}
@ -92,14 +100,11 @@ impl TestHarness {
/// This is the primary way to simulate user input. It:
/// 1. Sends the key event to the queue
/// 2. Processes all pending events (including any triggered by the key)
/// 3. For interactive mode, handles any reload commands
/// 3. Waits for reader (if running) and matcher to complete
pub fn key(&mut self, key: KeyEvent) -> Result<()> {
self.send(Event::Key(key))?;
self.tick()?;
// Wait for matcher if items changed
if self.app.pending_matcher_restart || !self.app.matcher_control.stopped() {
self.wait_for_matcher()?;
}
self.wait_for_completion()?;
Ok(())
}
@ -120,8 +125,33 @@ impl TestHarness {
pub fn action(&mut self, action: Action) -> Result<()> {
self.send(Event::Action(action))?;
self.tick()?;
// Wait for matcher if items changed
if self.app.pending_matcher_restart || !self.app.matcher_control.stopped() {
self.wait_for_completion()?;
Ok(())
}
/// Wait for any in-flight reader and matcher to complete.
///
/// If the reader is still running (e.g., after a reload in interactive mode),
/// waits for it to finish first. Then waits for the matcher if it needs to run.
///
/// If a debounced matcher restart is pending (e.g., because the query changed
/// within the 50ms debounce window), this forces the restart immediately so
/// tests don't see stale results.
fn wait_for_completion(&mut self) -> Result<()> {
// If a debounced matcher restart is pending, force it now.
// In production, the Heartbeat event would trigger this, but in tests
// we don't have a continuous heartbeat timer.
if self.skim.app().pending_matcher_restart {
self.skim.app_mut().restart_matcher(true);
}
// If the reader is running (not done), wait for it + matcher
if !self.skim.reader_done() {
self.wait_for_reader_and_matcher()?;
} else {
// Always wait for the matcher to ensure results are consumed.
// Even if stopped() is already true, wait_for_matcher will
// render and process heartbeat to consume processed_items.
self.wait_for_matcher()?;
}
Ok(())
@ -129,15 +159,16 @@ impl TestHarness {
/// Render the current app state to the terminal buffer.
pub fn render(&mut self) -> Result<()> {
self.tui.draw(|frame| {
frame.render_widget(&mut self.app, frame.area());
let (app, tui) = self.skim.app_and_tui();
tui.draw(|frame| {
frame.render_widget(&mut *app, frame.area());
})?;
Ok(())
}
/// Get a string representation of the current buffer for snapshot testing.
pub fn buffer_view(&self) -> String {
self.tui.backend().to_string()
self.skim.tui_ref().backend().to_string()
}
/// Prepare for taking a snapshot by waiting for preview and processing heartbeat.
@ -154,7 +185,7 @@ impl TestHarness {
self.tick()?;
// Now wait for preview if configured
if self.app.options.preview.is_some() {
if self.skim.app().options.preview.is_some() {
self.wait_for_preview()?;
}
@ -170,116 +201,38 @@ impl TestHarness {
pub fn snap(&mut self) -> Result<()> {
self.prepare_snap()?;
let buf = self.buffer_view();
let cursor_pos = format!("cursor: {}x{}", self.app.cursor_pos.0, self.app.cursor_pos.1);
let cursor_pos = format!(
"cursor: {}x{}",
self.skim.app().cursor_pos.0,
self.skim.app().cursor_pos.1
);
insta::assert_snapshot!(buf + &cursor_pos);
Ok(())
}
/// Add items to the item pool and run the matcher.
pub fn add_items<I, S>(&mut self, items: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
// Parse field ranges from options
let transform_fields: Vec<FieldRange> = self
.app
.options
.with_nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
/// Wait for the reader to finish producing items and the matcher to complete.
///
/// This polls `Skim::reader_done()` and `Skim::check_reader()` — the same
/// methods used by the production event loop — until the reader has finished,
/// then waits for the matcher to process all items.
pub fn wait_for_reader_and_matcher(&mut self) -> Result<()> {
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
let poll_interval = std::time::Duration::from_millis(10);
let matching_fields: Vec<FieldRange> = self
.app
.options
.nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
// Wait for reader to finish
while !self.skim.reader_done() {
if start.elapsed() > timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for reader to finish"));
}
// Check reader status (may restart matcher)
self.skim.check_reader();
std::thread::sleep(poll_interval);
}
let items: Vec<Arc<dyn SkimItem>> = items
.into_iter()
.enumerate()
.map(|(idx, s)| {
Arc::new(DefaultSkimItem::new(
s.as_ref(),
self.app.options.ansi,
&transform_fields,
&matching_fields,
&self.app.options.delimiter,
idx,
)) as Arc<dyn SkimItem>
})
.collect();
self.app.handle_items(items);
self.app.restart_matcher(true);
self.wait_for_matcher()?;
Ok(())
}
// Final check to restart matcher with remaining items
self.skim.check_reader();
/// Execute a shell command and add its output lines as items.
/// This is an internal method that doesn't restart the matcher.
fn run_command_internal(&mut self, cmd: &str) -> Result<()> {
let mut child = Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?;
let reader = BufReader::new(stdout);
// Parse field ranges from options
let transform_fields: Vec<FieldRange> = self
.app
.options
.with_nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
let matching_fields: Vec<FieldRange> = self
.app
.options
.nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
let items: Vec<Arc<dyn SkimItem>> = reader
.lines()
.map_while(Result::ok)
.enumerate()
.map(|(idx, s)| {
Arc::new(DefaultSkimItem::new(
&s,
self.app.options.ansi,
&transform_fields,
&matching_fields,
&self.app.options.delimiter,
idx,
)) as Arc<dyn SkimItem>
})
.collect();
self.app.handle_items(items);
// Wait for the command to complete and capture its exit status
let status = child.wait()?;
self.exit_status = status.code();
Ok(())
}
/// Execute a shell command and add its output lines as items.
pub fn run_command(&mut self, cmd: &str) -> Result<()> {
self.run_command_internal(cmd)?;
self.app.restart_matcher(true);
self.wait_for_matcher()?;
Ok(())
}
@ -291,7 +244,7 @@ impl TestHarness {
let poll_interval = std::time::Duration::from_millis(10);
// Wait for matcher to complete
while !self.app.matcher_control.stopped() {
while !self.skim.app().matcher_control.stopped() {
if start.elapsed() > timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for matcher to stop"));
}
@ -311,7 +264,8 @@ impl TestHarness {
// Manually trigger preview if configured and an item is selected
// Note: on_item_changed won't trigger automatically because the item was
// already selected during render, so prev_item == new_item
if self.app.options.preview.is_some() && self.app.item_list.selected().is_some() {
let needs_preview = self.skim.app().options.preview.is_some() && self.skim.app().item_list.selected().is_some();
if needs_preview {
self.send(Event::RunPreview)?;
self.wait_for_preview()?;
}
@ -326,7 +280,7 @@ impl TestHarness {
// Now check if there's a pending preview task
// If not, there's nothing to wait for
if let Some(ref handle) = self.app.preview.thread_handle {
if let Some(ref handle) = self.skim.app().preview.thread_handle {
if handle.is_finished() {
return Ok(());
}
@ -343,8 +297,12 @@ impl TestHarness {
// Sleep to give background tasks time to execute
std::thread::sleep(std::time::Duration::from_millis(50));
// Try to process any pending events (including PreviewReady)
while let Ok(event) = self.tui.event_rx.try_recv() {
// Drain events then process, so we can check for PreviewReady
let mut events = Vec::new();
while let Ok(event) = self.skim.tui_mut().event_rx.try_recv() {
events.push(event);
}
for event in events {
let is_preview_ready = matches!(event, Event::PreviewReady);
self.process_event(event)?;
// If we got PreviewReady, render and return
@ -375,7 +333,7 @@ impl TestHarness {
/// - 0 if the app accepted (Enter)
/// - None if the app hasn't quit yet
pub fn app_exit_code(&self) -> Option<i32> {
if !self.app.should_quit {
if !self.skim.app().should_quit {
return None;
}
@ -395,25 +353,44 @@ impl TestHarness {
// Factory functions
// ============================================================================
/// Initialize a test harness with the given options and dimensions.
pub fn enter_sized(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness> {
/// Initialize a test harness with the given options, dimensions, and optional item source.
///
/// Uses [`Skim::init`] for the core initialization (App, Reader, theme, etc.)
/// and [`Skim::init_tui_with`] to inject a [`TestBackend`].
/// Then calls [`Skim::start`] to begin the reader and matcher — the same
/// production code path used by the real application.
fn enter_sized_with_source(
options: SkimOptions,
width: u16,
height: u16,
source: Option<SkimItemReceiver>,
) -> Result<TestHarness> {
let backend = TestBackend::new(width, height);
let tui = Tui::new_for_test(backend)?;
let theme = Arc::new(ColorTheme::init_from_options(&options));
let cmd = options.cmd.clone().unwrap_or_default();
let app = App::from_options(options, theme, cmd);
let mut skim = Skim::<TestBackend>::init(options, source)?;
skim.init_tui_with(tui);
// Start the reader and matcher — the same call the production binary makes
skim.start();
// Create a multi-threaded tokio runtime for async operations (preview commands, etc.)
// We use multi-threaded so spawned tasks can execute on background threads
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(TestHarness {
tui,
app,
let mut harness = TestHarness {
skim,
runtime,
exit_status: None,
final_event: None,
})
};
// Wait for the reader to finish and matcher to complete
harness.wait_for_reader_and_matcher()?;
Ok(harness)
}
/// Initialize a test harness with the given options and dimensions.
pub fn enter_sized(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness> {
enter_sized_with_source(options, width, height, None)
}
/// Initialize a test harness with default dimensions (80x24).
@ -427,36 +404,65 @@ pub fn enter_default() -> Result<TestHarness> {
}
/// Initialize a test harness with pre-loaded items.
///
/// Items are fed through the production `Reader` + `SkimItemReader` pipeline
/// by converting them to a newline-separated byte stream and using
/// `SkimItemReader::of_bufread()`.
pub fn enter_items<I, S>(items: I, options: SkimOptions) -> Result<TestHarness>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut harness = enter(options)?;
harness.add_items(items)?;
Ok(harness)
// Build a newline-terminated string from items and feed through SkimItemReader.
// Each item must end with '\n' so the reader produces one item per entry,
// including empty-string items like "".
let text: String = items
.into_iter()
.map(|s| {
let mut line = s.as_ref().to_owned();
line.push('\n');
line
})
.collect();
let reader_opts = SkimItemReaderOption::from_options(&options);
let item_reader = SkimItemReader::new(reader_opts);
let rx = item_reader.of_bufread(Cursor::new(text));
enter_sized_with_source(options, 80, 24, Some(rx))
}
/// Initialize a test harness with command output as items.
///
/// The command is executed through the production `Reader` + `SkimItemReader`
/// pipeline, identical to how the real application works.
pub fn enter_cmd(cmd: &str, options: SkimOptions) -> Result<TestHarness> {
let mut harness = enter(options)?;
harness.run_command(cmd)?;
Ok(harness)
let reader_opts = SkimItemReaderOption::from_options(&options);
let item_reader = SkimItemReader::new(reader_opts);
let rx = item_reader.of_bufread(std::io::BufReader::new(
std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?
.stdout
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?,
));
enter_sized_with_source(options, 80, 24, Some(rx))
}
/// Initialize a test harness for interactive mode.
///
/// This runs the initial command (with empty query) and sets up the harness.
/// Uses [`Skim::start`] which already handles interactive mode correctly:
/// it expands the command template with the initial query and starts the
/// reader pipeline.
pub fn enter_interactive(options: SkimOptions) -> Result<TestHarness> {
let mut harness = enter(options)?;
// Run initial command with current (empty) query
if let Some(ref cmd_template) = harness.app.options.cmd.clone() {
let expanded_cmd = harness.app.expand_cmd(cmd_template, true);
harness.run_command(&expanded_cmd)?;
}
Ok(harness)
// Skim::init() computes initial_cmd for interactive mode,
// and Skim::start() kicks off the reader with it.
// No special-casing needed here.
enter(options)
}
/// Parse SkimOptions from CLI-style arguments.
@ -479,8 +485,8 @@ macro_rules! snap {
let buf = $harness.buffer_view();
let cursor_pos = format!(
"cursor: ({}, {})",
$harness.app.cursor_pos.1 + 1,
$harness.app.cursor_pos.0 + 1
$harness.skim.app().cursor_pos.1 + 1,
$harness.skim.app().cursor_pos.0 + 1
);
insta::assert_snapshot!(buf + &cursor_pos);
};
@ -701,8 +707,8 @@ macro_rules! insta_test {
// @assert - run an assertion closure
// Pass a closure that takes the harness as parameter
// Usage: @assert(|h| h.app.should_quit);
// @assert(|h| h.app.item_list.selected().unwrap().text() == "1");
// Usage: @assert(|h| h.skim.app().should_quit);
// @assert(|h| h.skim.app().item_list.selected().unwrap().text() == "1");
(@expand $h:ident; @assert ( $assertion:expr ) ; $($rest:tt)*) => {
assert!(($assertion)(&$h));
insta_test!(@expand $h; $($rest)*);

View file

@ -11,7 +11,7 @@ insta_test!(keys_interactive_basic, ["1", "2", "3", "4"], &["-i"], {
// Input navigation keys
insta_test!(keys_interactive_arrows, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_arrows, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@key Left;
@char '|';
@ -21,7 +21,7 @@ insta_test!(keys_interactive_arrows, @interactive, &["-i", "--cmd-query", "foo b
@snap;
});
insta_test!(keys_interactive_ctrl_arrows, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_arrows, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl Left;
@char '|';
@ -34,14 +34,14 @@ insta_test!(keys_interactive_ctrl_arrows, @interactive, &["-i", "--cmd-query", "
@snap;
});
insta_test!(keys_interactive_ctrl_a, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_a, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'a';
@char '|';
@snap;
});
insta_test!(keys_interactive_ctrl_b, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_b, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'a';
@char '|';
@ -51,7 +51,7 @@ insta_test!(keys_interactive_ctrl_b, @interactive, &["-i", "--cmd-query", "foo b
@snap;
});
insta_test!(keys_interactive_ctrl_e, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_e, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'a';
@char '|';
@ -61,7 +61,7 @@ insta_test!(keys_interactive_ctrl_e, @interactive, &["-i", "--cmd-query", "foo b
@snap;
});
insta_test!(keys_interactive_ctrl_f, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_f, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'a';
@char '|';
@ -71,21 +71,21 @@ insta_test!(keys_interactive_ctrl_f, @interactive, &["-i", "--cmd-query", "foo b
@snap;
});
insta_test!(keys_interactive_ctrl_h, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_h, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'h';
@char '|';
@snap;
});
insta_test!(keys_interactive_alt_b, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_alt_b, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@alt 'b';
@char '|';
@snap;
});
insta_test!(keys_interactive_alt_f, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_alt_f, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'a';
@char '|';
@ -97,39 +97,39 @@ insta_test!(keys_interactive_alt_f, @interactive, &["-i", "--cmd-query", "foo ba
// Input manipulation keys
insta_test!(keys_interactive_bspace, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_bspace, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@key Backspace;
@char '|';
@snap;
});
insta_test!(keys_interactive_ctrl_c, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_c, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'c';
@exited 130;
});
insta_test!(keys_interactive_ctrl_d, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_d, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'd';
@exited 130;
});
insta_test!(keys_interactive_ctrl_u, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_u, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'u';
@char '|';
@snap;
});
insta_test!(keys_interactive_ctrl_w, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_w, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl 'w';
@char '|';
@snap;
});
insta_test!(keys_interactive_ctrl_y, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_ctrl_y, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@alt Backspace;
@char '|';
@ -139,7 +139,7 @@ insta_test!(keys_interactive_ctrl_y, @interactive, &["-i", "--cmd-query", "foo b
@snap;
});
insta_test!(keys_interactive_alt_d, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_alt_d, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@ctrl Left;
@char '|';
@ -152,7 +152,7 @@ insta_test!(keys_interactive_alt_d, @interactive, &["-i", "--cmd-query", "foo ba
@snap;
});
insta_test!(keys_interactive_alt_bspace, @interactive, &["-i", "--cmd-query", "foo bar foo-bar"], {
insta_test!(keys_interactive_alt_bspace, @interactive, &["-i", "--cmd", "true", "--cmd-query", "foo bar foo-bar"], {
@snap;
@alt Backspace;
@char '|';
@ -186,6 +186,6 @@ insta_test!(keys_interactive_btab, ["1", "2", "3", "4"], &["-i"], {
insta_test!(keys_interactive_enter, ["1", "2", "3", "4"], &["-i"], {
@snap;
@key Enter;
@assert(|h: &common::insta::TestHarness| h.app.should_quit);
@assert(|h: &common::insta::TestHarness| h.app.item_list.selected().unwrap().text() == "1");
@assert(|h: &common::insta::TestHarness| h.skim.app().should_quit);
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "1");
});