Skip to main content

skim/
lib.rs

1//! Skim is a fuzzy finder library for Rust.
2//!
3//! It provides a fast and customizable way to filter and select items interactively,
4//! similar to fzf. Skim can be used as a library or as a command-line tool.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use skim::prelude::*;
10//! use std::io::Cursor;
11//!
12//! let options = SkimOptionsBuilder::default()
13//!     .height(Some("50%"))
14//!     .multi(true)
15//!     .build()
16//!     .unwrap();
17//!
18//! let input = "awk\nbash\ncsh\ndash\nfish\nksh\nzsh";
19//! let item_reader = SkimItemReader::default();
20//! let items = item_reader.of_bufread(Cursor::new(input));
21//!
22//! let output = Skim::run_with(&options, Some(items)).unwrap();
23//! ```
24
25#![warn(missing_docs)]
26
27#[macro_use]
28extern crate log;
29
30use std::any::Any;
31use std::borrow::Cow;
32use std::env;
33use std::fmt::Display;
34use std::sync::Arc;
35use std::sync::atomic::AtomicBool;
36use std::time::{Duration, Instant};
37
38use color_eyre::eyre::Result;
39use color_eyre::eyre::{self, OptionExt};
40use crossterm::event::KeyCode;
41use crossterm::event::KeyEvent;
42use crossterm::event::KeyModifiers;
43use ratatui::prelude::CrosstermBackend;
44use ratatui::style::Style;
45use ratatui::text::{Line, Span};
46use reader::Reader;
47use tokio::select;
48use tokio::sync::mpsc::error::TryRecvError;
49use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
50use tui::App;
51use tui::Event;
52use tui::Size;
53
54pub use crate::engine::fuzzy::FuzzyAlgorithm;
55pub use crate::item::RankCriteria;
56pub use crate::options::SkimOptions;
57pub use crate::output::SkimOutput;
58pub use crate::skim_item::SkimItem;
59use crate::tui::event::Action;
60
61pub mod binds;
62mod engine;
63pub mod field;
64pub mod fuzzy_matcher;
65pub mod helper;
66pub mod item;
67pub mod matcher;
68pub mod options;
69mod output;
70pub mod prelude;
71pub mod reader;
72mod skim_item;
73pub mod spinlock;
74pub mod theme;
75pub mod tmux;
76pub mod tui;
77mod util;
78
79#[cfg(feature = "cli")]
80pub mod completions;
81#[cfg(feature = "cli")]
82pub mod manpage;
83
84//------------------------------------------------------------------------------
85/// Trait for downcasting to concrete types from trait objects
86pub trait AsAny {
87    /// Returns a reference to the value as `Any`
88    fn as_any(&self) -> &dyn Any;
89    /// Returns a mutable reference to the value as `Any`
90    fn as_any_mut(&mut self) -> &mut dyn Any;
91}
92
93impl<T: Any> AsAny for T {
94    fn as_any(&self) -> &dyn Any {
95        self
96    }
97
98    fn as_any_mut(&mut self) -> &mut dyn Any {
99        self
100    }
101}
102
103//------------------------------------------------------------------------------
104// Display Context
105#[derive(Default, Debug, Clone)]
106/// Represents how a query matches an item
107pub enum Matches {
108    /// No matches
109    #[default]
110    None,
111    /// Matches at specific character indices
112    CharIndices(Vec<usize>),
113    /// Matches in a character range (start, end)
114    CharRange(usize, usize),
115    /// Matches in a byte range (start, end)
116    ByteRange(usize, usize),
117}
118
119#[derive(Default, Clone)]
120/// Context information for displaying an item
121pub struct DisplayContext {
122    /// The match score for this item
123    pub score: i32,
124    /// Where the query matched in the item
125    pub matches: Matches,
126    /// The width of the container to display in
127    pub container_width: usize,
128    /// The base style to apply to non-matched portions
129    pub base_style: Style,
130    /// The style to apply to matched portions
131    pub matched_syle: Style,
132}
133
134impl DisplayContext {
135    /// Converts the context and text into a styled `Line` with highlighted matches
136    pub fn to_line(self, cow: Cow<str>) -> Line {
137        let text: String = cow.into_owned();
138
139        // Combine base_style with match style for highlighted text
140        // Match style takes precedence for fg, but inherits bg from base if not set
141        match &self.matches {
142            Matches::CharIndices(indices) => {
143                let mut res = Line::default();
144                let mut chars = text.chars();
145                let mut prev_index = 0;
146                for &index in indices {
147                    let span_content = chars.by_ref().take(index - prev_index);
148                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
149                    let highlighted_char = chars.next().unwrap_or_default().to_string();
150
151                    res.push_span(Span::styled(highlighted_char, self.base_style.patch(self.matched_syle)));
152                    prev_index = index + 1;
153                }
154                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
155                res
156            }
157            // AnsiString::from((context.text, indices, context.highlight_attr)),
158            #[allow(clippy::cast_possible_truncation)]
159            Matches::CharRange(start, end) => {
160                let mut chars = text.chars();
161                let mut res = Line::default();
162                res.push_span(Span::styled(
163                    chars.by_ref().take(*start).collect::<String>(),
164                    self.base_style,
165                ));
166                let highlighted_text = chars.by_ref().take(*end - *start).collect::<String>();
167
168                res.push_span(Span::styled(highlighted_text, self.base_style.patch(self.matched_syle)));
169                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
170                res
171            }
172            Matches::ByteRange(start, end) => {
173                let mut bytes = text.bytes();
174                let mut res = Line::default();
175                res.push_span(Span::styled(
176                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
177                    self.base_style,
178                ));
179                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
180                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
181
182                res.push_span(Span::styled(highlighted_text, self.base_style.patch(self.matched_syle)));
183                res.push_span(Span::styled(
184                    String::from_utf8(bytes.collect()).unwrap(),
185                    self.base_style,
186                ));
187                res
188            }
189            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
190        }
191    }
192}
193
194//------------------------------------------------------------------------------
195// Preview Context
196
197/// Context information for generating item previews
198pub struct PreviewContext<'a> {
199    /// The current search query
200    pub query: &'a str,
201    /// The current command query (for interactive mode)
202    pub cmd_query: &'a str,
203    /// Width of the preview window
204    pub width: usize,
205    /// Height of the preview window
206    pub height: usize,
207    /// Index of the current item
208    pub current_index: usize,
209    /// Text of the current selection
210    pub current_selection: &'a str,
211    /// selected item indices (may or may not include current item)
212    pub selected_indices: &'a [usize],
213    /// selected item texts (may or may not include current item)
214    pub selections: &'a [&'a str],
215}
216
217//------------------------------------------------------------------------------
218// Preview
219#[derive(Default, Copy, Clone, Debug)]
220/// Position and scroll information for preview display
221pub struct PreviewPosition {
222    /// Horizontal scroll position
223    pub h_scroll: Size,
224    /// Horizontal offset
225    pub h_offset: Size,
226    /// Vertical scroll position
227    pub v_scroll: Size,
228    /// Vertical offset
229    pub v_offset: Size,
230}
231
232/// Defines how an item should be previewed
233pub enum ItemPreview {
234    /// execute the command and print the command's output
235    Command(String),
236    /// Display the prepared text(lines)
237    Text(String),
238    /// Display the colored text(lines)
239    AnsiText(String),
240    /// Execute a command and display output with position
241    CommandWithPos(String, PreviewPosition),
242    /// Display text with position
243    TextWithPos(String, PreviewPosition),
244    /// Display ANSI-colored text with position
245    AnsiWithPos(String, PreviewPosition),
246    /// Use global command settings to preview the item
247    Global,
248}
249
250//==============================================================================
251// A match engine will execute the matching algorithm
252
253#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
254#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
255#[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))]
256/// Case sensitivity mode for matching
257pub enum CaseMatching {
258    /// Case-sensitive matching
259    Respect,
260    /// Case-insensitive matching
261    Ignore,
262    /// Smart case: case-insensitive unless query contains uppercase
263    #[default]
264    Smart,
265}
266
267#[derive(PartialEq, Eq, Clone, Debug)]
268#[allow(dead_code)]
269/// Represents the range of a match in an item
270pub enum MatchRange {
271    /// Range of bytes (start, end)
272    ByteRange(usize, usize),
273    /// Individual character indices that matched
274    Chars(Vec<usize>),
275}
276
277/// Rank tuple used for sorting match results
278pub type Rank = [i32; 5];
279
280#[derive(Clone)]
281/// Result of matching a query against an item
282pub struct MatchResult {
283    /// The rank/score of this match
284    pub rank: Rank,
285    /// The range where the match occurred
286    pub matched_range: MatchRange,
287}
288
289impl MatchResult {
290    #[must_use]
291    /// Converts the match range to character indices
292    pub fn range_char_indices(&self, text: &str) -> Vec<usize> {
293        match &self.matched_range {
294            &MatchRange::ByteRange(start, end) => {
295                let first = text[..start].chars().count();
296                let last = first + text[start..end].chars().count();
297                (first..last).collect()
298            }
299            MatchRange::Chars(vec) => vec.clone(),
300        }
301    }
302}
303
304/// A matching engine that can match queries against items
305pub trait MatchEngine: Sync + Send + Display {
306    /// Matches an item against the query, returning a result if matched
307    fn match_item(&self, item: Arc<dyn SkimItem>) -> Option<MatchResult>;
308}
309
310/// Factory for creating match engines
311pub trait MatchEngineFactory {
312    /// Creates a match engine with explicit case sensitivity
313    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine>;
314    /// Creates a match engine with default case sensitivity
315    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
316        self.create_engine_with_case(query, CaseMatching::default())
317    }
318}
319
320//------------------------------------------------------------------------------
321// Preselection
322
323/// A selector that determines whether an item should be "pre-selected" in multi-selection mode
324pub trait Selector {
325    /// Returns true if the item at the given index should be pre-selected
326    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
327}
328
329//------------------------------------------------------------------------------
330/// Sender for streaming items to skim
331pub type SkimItemSender = UnboundedSender<Arc<dyn SkimItem>>;
332/// Receiver for streaming items to skim
333pub type SkimItemReceiver = UnboundedReceiver<Arc<dyn SkimItem>>;
334
335/// Main entry point for running skim
336pub struct Skim {}
337
338impl Skim {
339    /// # Params
340    ///
341    /// - options: the "complex" options that control how skim behaves
342    /// - source: a stream of items to be passed to skim for filtering.
343    ///   If None is given, skim will invoke the command given to fetch the items.
344    ///
345    /// # Returns
346    ///
347    /// - None: on internal errors.
348    /// - `SkimOutput`: the collected key, event, query, selected items, etc.
349    ///
350    /// # Panics
351    ///
352    /// Panics if the tui fails to initilize
353    pub fn run_with(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<SkimOutput> {
354        let height = Size::try_from(options.height.as_str())?;
355
356        // application state
357        // Initialize theme from options
358        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
359        let mut reader = Reader::from_options(&options).source(source);
360        const SKIM_DEFAULT_COMMAND: &str = "find .";
361        let default_command = String::from(match env::var("SKIM_DEFAULT_COMMAND").as_deref() {
362            Err(_) | Ok("") => SKIM_DEFAULT_COMMAND,
363            Ok(v) => v,
364        });
365        let cmd = options.cmd.clone().unwrap_or(default_command);
366        let listen_socket = options.listen.clone();
367
368        let mut app = App::from_options(options, theme.clone(), cmd.clone());
369
370        let rt = tokio::runtime::Runtime::new()?;
371        let mut final_event: Event = Event::Quit;
372        let mut final_key: KeyEvent = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
373        rt.block_on(async {
374            //------------------------------------------------------------------------------
375            // reader
376            // In interactive mode, expand all placeholders ({}, {q}, etc) with initial query (empty or from --query)
377            let initial_cmd = if app.options.interactive && app.options.cmd.is_some() {
378                let expanded = app.expand_cmd(&cmd, true);
379                log::debug!(
380                    "Interactive mode: initial_cmd = {:?} (from template {:?})",
381                    expanded,
382                    cmd
383                );
384                expanded
385            } else {
386                cmd.clone()
387            };
388            log::debug!("Starting reader with initial_cmd: {:?}", initial_cmd);
389            let (item_tx, mut item_rx) = unbounded_channel();
390            let mut reader_control = reader.run(item_tx.clone(), &initial_cmd);
391
392            //------------------------------------------------------------------------------
393            // model + previewer
394
395            let mut matcher_interval = tokio::time::interval(Duration::from_millis(100));
396            let reader_done = Arc::new(AtomicBool::new(false));
397            let reader_done_clone = reader_done.clone();
398            let (reader_interrupt_tx, mut reader_interrupt_rx) = unbounded_channel();
399
400            let item_pool = app.item_pool.clone();
401            tokio::spawn(async move {
402                const BATCH: usize = 4096; // Smaller batches for more responsive updates
403                loop {
404                    if reader_interrupt_rx
405                        .try_recv()
406                        .unwrap_or_else(|e| e == TryRecvError::Disconnected)
407                    {
408                        debug!("stopping reader receiver thread");
409                        break;
410                    }
411                    let mut buf = Vec::with_capacity(BATCH);
412                    trace!("getting items");
413                    if item_rx.recv_many(&mut buf, BATCH).await > 0 {
414                        item_pool.append(buf);
415                        trace!("Got new items, len {}", item_pool.len());
416                    } else {
417                        reader_done_clone.store(true, std::sync::atomic::Ordering::Relaxed);
418                    }
419                }
420            });
421
422            // Start matcher initially
423            app.restart_matcher(true);
424
425            // Deal with read-0 / select-1
426            let min_items_before_enter = if app.options.exit_0 {
427                1
428            } else if app.options.select_1 {
429                2
430            } else {
431                0
432            };
433
434            let mut should_enter = true;
435            if min_items_before_enter > 0 {
436                while app.matcher_control.get_num_matched() < min_items_before_enter
437                    && !app.matcher_control.stopped()
438                    && !reader_done.load(std::sync::atomic::Ordering::Relaxed) {
439                    let curr = app.matcher_control.get_num_matched();
440                    tokio::time::sleep(Duration::from_millis(10)).await;
441                    trace!(
442                        "waiting for matcher, stopped: {}, processed: {}, pool: {}, matched: {}, query: {}, reader_done: {}, reader_control_done: {}",
443                        app.matcher_control.stopped(),
444                        app.matcher_control.get_num_processed(),
445                        app.item_pool.num_not_taken(),
446                        curr,
447                        app.input.value,
448                        reader_done.load(std::sync::atomic::Ordering::Relaxed),
449                        reader_control.is_done()
450                    );
451                    app.restart_matcher(false);
452                };
453                trace!("checking for matched item count before entering: {}/{min_items_before_enter}", app.matcher_control.get_num_matched());
454                if app.matcher_control.get_num_matched() == min_items_before_enter - 1
455                {
456                    app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
457                    debug!("early exit, result: {:?}", app.results());
458                    should_enter = false;
459                    final_event = Event::Action(Action::Accept(None));
460                }
461            }
462
463            if should_enter {
464                let listener = if let Some(socket_name) = &listen_socket {
465                    debug!("starting listener on socket at {socket_name}");
466                    Some(
467                        interprocess::local_socket::ListenerOptions::new()
468                            .name(
469                                interprocess::local_socket::ToNsName::to_ns_name::<
470                                    interprocess::local_socket::GenericNamespaced,
471                                >(socket_name.as_str())
472                                .unwrap_or_else(|e| {
473                                    panic!("Failed to build full name for IPC listener from {socket_name}: {e}")
474                                }),
475                            )
476                            .create_tokio()
477                            .unwrap_or_else(|e| panic!("Failed to create tokio IPC listener at {socket_name}: {e}")),
478                    )
479                } else {
480                    None
481                };
482                let backend = CrosstermBackend::new(std::io::BufWriter::new(std::io::stderr()));
483                let mut tui = tui::Tui::new_with_height(backend, height)?;
484                let event_tx_clone = tui.event_tx.clone();
485                tui.enter()?;
486                loop {
487                    select! {
488                        event = tui.next() => {
489                            let evt = event.ok_or_eyre("Could not acquire next event")?;
490
491                            // Handle reload event
492                            if let Event::Reload(new_cmd) = &evt {
493                                debug!("reloading with cmd {new_cmd}");
494                                // Kill the current reader
495                                reader_control.kill();
496                                // Clear items
497                                app.item_pool.clear();
498                                // Clear displayed items unless no_clear_if_empty is set
499                                // (in which case the item_list will handle keeping stale items)
500                                if !app.options.no_clear_if_empty {
501                                    app.item_list.clear();
502                                }
503                                app.restart_matcher(true);
504                                // Start a new reader with the new command (no source, using cmd)
505                                reader_control = reader.run(item_tx.clone(), new_cmd);
506                                reader_done.store(false, std::sync::atomic::Ordering::Relaxed);
507                            }
508                            if let Event::Key(k) = &evt {
509                              final_key = k.to_owned();
510                            } else {
511                              final_event = evt.to_owned();
512                            }
513
514                            // Check reader status and update
515                            if !reader_control.is_done() {
516                              app.reader_timer = Instant::now();
517                            } else if ! reader_done.load(std::sync::atomic::Ordering::Relaxed) {
518                                reader_done.store(true, std::sync::atomic::Ordering::Relaxed);
519                                app.restart_matcher(true);
520                            }
521                            app.handle_event(&mut tui, &evt)?;
522                        }
523                        _ = matcher_interval.tick() => {
524                          app.restart_matcher(false);
525                        }
526                        Ok(stream) = async {
527                            match &listener {
528                                Some(l) => interprocess::local_socket::traits::tokio::Listener::accept(l).await,
529                                None => std::future::pending().await,
530                            }
531                        } => {
532                            let event_tx_clone_ipc = event_tx_clone.clone();
533                            tokio::spawn(async move {
534                                use tokio::io::AsyncBufReadExt;
535                                let reader = tokio::io::BufReader::new(stream);
536                                let mut lines = reader.lines();
537                                while let Ok(Some(line)) = lines.next_line().await {
538                                    debug!("listener: got {line}");
539                                    if let Ok(act) = ron::from_str::<Action>(&line) {
540                                        debug!("listener: parsed into action {act:?}");
541                                        _ = event_tx_clone_ipc.send(Event::Action(act));
542                                        _ = event_tx_clone_ipc.send(Event::Render);
543                                    }
544                                }
545                            });
546                        }
547                    }
548
549                    if app.should_quit {
550                        break;
551                    }
552                }
553            }
554            reader_control.kill();
555            let _ = reader_interrupt_tx.send(true);
556            eyre::Ok(())
557        })?;
558
559        // Extract final_key and is_abort from final_event
560        let is_abort = !matches!(&final_event, Event::Action(Action::Accept(_)));
561
562        Ok(SkimOutput {
563            cmd: if app.options.interactive {
564                // In interactive mode, cmd is what the user typed
565                app.input.to_string()
566            } else if app.options.cmd_query.is_some() {
567                // If cmd_query was provided, use that for output
568                app.options.cmd_query.clone().unwrap()
569            } else {
570                // Otherwise use the execution command
571                cmd
572            },
573            final_event,
574            final_key,
575            query: app.input.to_string(),
576            is_abort,
577            selected_items: app.results(),
578            header: app.header.header,
579        })
580    }
581}